Compare commits

..

3 Commits

205 changed files with 485865 additions and 721 deletions

View File

@@ -172,7 +172,9 @@ class NewsController extends Controller
$userId = Auth::id();
$session = 'news_view_' . $article->id;
if ($request->session()->has($session)) {
$canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped');
if ($canReadSession && $request->session()->has($session)) {
return;
}
@@ -185,7 +187,9 @@ class NewsController extends Controller
$article->incrementViews();
$request->session()->put($session, true);
if ($canReadSession) {
$request->session()->put($session, true);
}
}
private function sidebarData(): array

View File

@@ -92,7 +92,7 @@ final class SitemapCacheService
{
$prefix = trim((string) config('sitemaps.pre_generated.path', 'generated-sitemaps'), '/');
$segments = $name === self::INDEX_DOCUMENT
? [$prefix, 'sitemap.xml']
? [$prefix, 'sitemaps', 'sitemap.xml']
: [$prefix, 'sitemaps', $name . '.xml'];
return implode('/', array_values(array_filter($segments, static fn (string $segment): bool => $segment !== '')));

View File

@@ -124,7 +124,7 @@ final class SitemapReleaseManager
public function documentRelativePath(string $documentName): string
{
return $documentName === SitemapCacheService::INDEX_DOCUMENT
? 'sitemap.xml'
? 'sitemaps/sitemap.xml'
: 'sitemaps/' . $documentName . '.xml';
}

View File

@@ -107,7 +107,7 @@ export default function ArtworkMaturityQueue() {
], [stats])
return (
<div className="mx-auto max-w-7xl px-4 pb-16 pt-8 sm:px-6 lg:px-8">
<div className="w-full pb-16 pt-8">
<Head title="Artwork Maturity Queue" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.88))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">

View File

@@ -56,6 +56,7 @@ export default function Topbar({ user = null }) {
<div className="border-t border-neutral-700" />
<a href={user.uploadUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Upload</a>
<a href="/studio/artworks" className="block px-4 py-2 text-sm hover:bg-white/5">Studio</a>
{user.moderationUrl ? <a href={user.moderationUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Moderation</a> : null}
<a href="/dashboard" className="block px-4 py-2 text-sm hover:bg-white/5">Dashboard</a>
<div className="border-t border-neutral-700" />
<a href="/logout" className="block px-4 py-2 text-sm text-red-400 hover:bg-white/5"

View File

@@ -13,6 +13,7 @@ function mount() {
username: container.dataset.username || '',
avatarUrl: container.dataset.avatarUrl || null,
uploadUrl: container.dataset.uploadUrl || '/upload',
moderationUrl: container.dataset.moderationUrl || null,
}
: null

View File

@@ -245,6 +245,7 @@
data-username="{{ Auth::user()->username ?? '' }}"
data-avatar-url="{{ \App\Support\AvatarUrl::forUser((int) Auth::id(), optional(Auth::user()->profile)->avatar_hash, 64) }}"
data-upload-url="{{ Route::has('upload') ? route('upload') : '/upload' }}"
data-moderation-url="{{ in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true) ? '/moderation' : '' }}"
@endif
></div>
@include('layouts.nova.toolbar')

View File

@@ -310,6 +310,7 @@
@php
$toolbarUsername = strtolower((string) (Auth::user()->username ?? ''));
$routeUpload = Route::has('upload') ? route('upload') : '/upload';
$routeModeration = '/moderation';
$routeDashboard = Route::has('dashboard') ? route('dashboard') : '/dashboard';
$routeMyArtworks = Route::has('studio.artworks') ? route('studio.artworks') : '/studio/artworks';
$routeMyStories = Route::has('creator.stories.index') ? route('creator.stories.index') : '/creator/stories';
@@ -376,6 +377,12 @@
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-palette text-xs text-sb-muted"></i></span>
Studio
</a>
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeModeration }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-shield text-xs text-sb-muted"></i></span>
Moderation
</a>
@endif
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeDashboard }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-table-columns text-xs text-sb-muted"></i></span>
Dashboard
@@ -401,13 +408,6 @@
Settings
</a>
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true) && \Illuminate\Support\Facades\Route::has('admin.usernames.moderation'))
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-user-shield text-xs text-sb-muted"></i></span>
Moderation
</a>
@endif
<div class="border-t border-panel mt-1 mb-1"></div>
<form method="POST" action="{{ route('logout') }}" class="mb-1">
@csrf

View File

@@ -5,6 +5,7 @@
$hero_description = "We're always grateful for volunteers who want to help.";
$center_content = true;
$center_max = '3xl';
$errors = $errors ?? new \Illuminate\Support\ViewErrorBag();
@endphp
@section('page-content')

View File

@@ -41,7 +41,7 @@ SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
# Skinbase Nova conditional public sessions
# Skinbase conditional public sessions
SKINBASE_CONDITIONAL_SESSIONS_ENABLED=true
SKINBASE_SKIP_ANONYMOUS_PUBLIC_GET_SESSIONS=true
SKINBASE_SKIP_BOT_PUBLIC_GET_SESSIONS=true

View File

@@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Storage;
/**
* Builds all sitemap documents and writes them as static .xml files to the
* public disk (default: public/sitemap.xml and public/sitemaps/{name}.xml).
* public disk (default: public/sitemaps/sitemap.xml and public/sitemaps/{name}.xml).
*
* Nginx can then serve those files directly (try_files $uri @php) without
* hitting PHP at all. The SitemapController falls back to these same files
@@ -25,11 +25,11 @@ final class GenerateSitemapsCommand extends Command
{--only=* : Limit to specific sitemap families (comma or space separated)}
{--disk= : Override the target filesystem disk (default: sitemaps.static_publish.disk)}';
protected $description = 'Build all sitemaps and write them as static .xml files to public/.';
protected $description = 'Build all sitemaps and write them as static .xml files to the configured public sitemap disk.';
public function handle(SitemapBuildService $build): int
{
$totalS tart = microtime(true);
$totalStart = microtime(true);
$families = $this->selectedFamilies($build);
if ($families === []) {
@@ -50,10 +50,10 @@ final class GenerateSitemapsCommand extends Command
// ── Root sitemap index ────────────────────────────────────────────
$t = microtime(true);
$index = $build->buildIndex(force: true, persist: false, families: $families);
$disk->put('sitemap.xml', $index['content']);
$disk->put('sitemaps/sitemap.xml', $index['content']);
$written++;
$this->line(sprintf(
' <info>✔</info> sitemap.xml %d entries <comment>%.3fs</comment>',
' <info>✔</info> sitemaps/sitemap.xml %d entries <comment>%.3fs</comment>',
$index['url_count'],
microtime(true) - $t,
));

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\LeaderboardService;
use Illuminate\Console\Command;
class RefreshLeaderboardsCommand extends Command
{
protected $signature = 'leaderboards:refresh';
protected $description = 'Refresh all leaderboard rows and clear leaderboard caches.';
public function __construct(private readonly LeaderboardService $leaderboards)
{
parent::__construct();
}
public function handle(): int
{
$this->info('Refreshing leaderboards …');
$results = $this->leaderboards->refreshAll();
$updated = collect($results)
->flatten(1)
->sum(fn (int $count): int => $count);
$this->info("Done. Updated: {$updated} leaderboard row(s).");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Console\Commands;
use App\Jobs\SendVerificationEmailJob;
use App\Mail\RegistrationVerificationMail;
use App\Models\EmailSendEvent;
use App\Models\User;
use App\Services\Auth\RegistrationEmailQuotaService;
use App\Services\Auth\RegistrationVerificationTokenService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
class SendUserVerificationEmailCommand extends Command
{
protected $signature = 'user:send-verification-email
{userId : The user ID that should receive the verification email}
{--now : Send immediately instead of queueing the existing verification job}
{--force : Allow sending even if the user is already verified}';
protected $description = 'Send the registration verification email to a specific user ID.';
public function __construct(
private readonly RegistrationVerificationTokenService $tokenService,
private readonly RegistrationEmailQuotaService $quotaService,
) {
parent::__construct();
}
public function handle(): int
{
$userId = (int) $this->argument('userId');
if ($userId < 1) {
$this->error('The user ID must be a positive integer.');
return self::FAILURE;
}
$user = User::query()->find($userId);
if (! $user) {
$this->error("User {$userId} was not found.");
return self::FAILURE;
}
$email = strtolower(trim((string) $user->email));
if ($email === '') {
$this->error("User {$userId} does not have an email address.");
return self::FAILURE;
}
if ($user->email_verified_at !== null && ! $this->option('force')) {
$this->error("User {$userId} already has a verified email address. Use --force to send anyway.");
return self::FAILURE;
}
$token = $this->tokenService->createForUser($userId);
$event = EmailSendEvent::query()->create([
'type' => 'verify_email',
'email' => $email,
'ip' => null,
'user_id' => $userId,
'status' => $this->option('now') ? 'pending' : 'queued',
'reason' => null,
'created_at' => now(),
]);
if ($this->option('now')) {
return $this->sendNow($user, $event, $token);
}
SendVerificationEmailJob::dispatch(
emailEventId: (int) $event->id,
email: $email,
token: $token,
userId: $userId,
ip: null,
);
$this->markVerificationEmailSent($user);
$this->info("Queued verification email for user {$userId} <{$email}>.");
return self::SUCCESS;
}
private function sendNow(User $user, EmailSendEvent $event, string $token): int
{
if (! $this->acquireGlobalSendSlot()) {
$this->updateEvent($event, 'blocked', 'rate_limited');
$this->error('The global verification email rate limit is currently exhausted. Try again in a minute.');
return self::FAILURE;
}
if ($this->quotaService->isExceeded()) {
$this->updateEvent($event, 'blocked', 'quota');
$this->error('The monthly registration email quota is exceeded.');
return self::FAILURE;
}
try {
Mail::to($user->email)->send(new RegistrationVerificationMail($token));
} catch (\Throwable $exception) {
$this->updateEvent($event, 'failed', 'send_error');
$this->error('Failed to send the verification email: ' . $exception->getMessage());
return self::FAILURE;
}
$this->quotaService->incrementSentCount();
$this->updateEvent($event, 'sent', null);
$this->markVerificationEmailSent($user);
$email = strtolower(trim((string) $user->email));
$this->info("Sent verification email to user {$user->id} <{$email}>.");
return self::SUCCESS;
}
private function acquireGlobalSendSlot(): bool
{
$key = 'registration:verification-email:global';
$maxPerMinute = max(1, (int) config('registration.email_global_send_per_minute', 30));
return RateLimiter::attempt($key, $maxPerMinute, static fn () => true, 60);
}
private function updateEvent(EmailSendEvent $event, string $status, ?string $reason): void
{
EmailSendEvent::query()
->whereKey($event->getKey())
->update([
'status' => $status,
'reason' => $reason,
]);
}
private function markVerificationEmailSent(User $user): void
{
$now = now();
$windowStartedAt = $user->verification_send_window_started_at;
if (! $windowStartedAt || $windowStartedAt->lt($now->copy()->subDay())) {
$user->verification_send_window_started_at = $now;
$user->verification_send_count_24h = 1;
} else {
$user->verification_send_count_24h = ((int) $user->verification_send_count_24h) + 1;
}
$user->last_verification_sent_at = $now;
$user->save();
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Admin;
use App\Enums\UserRole;
use App\Http\Controllers\Controller;
use App\Models\AuthAuditLog;
use App\Models\Artwork;
use App\Models\Story;
use App\Models\User;
@@ -157,4 +158,83 @@ final class AdminController extends Controller
'settings' => [],
]);
}
public function authAudit(Request $request): Response
{
abort_unless($request->user()?->isAdmin(), 403, 'Only admins can access this area.');
$search = $request->string('search')->trim()->toString();
$eventType = $request->string('event')->trim()->toString();
$status = $request->string('status')->trim()->toString();
$query = AuthAuditLog::query()
->with('user:id,name,username,email,role')
->latest('created_at')
->latest('id');
if ($search !== '') {
$query->where(function ($builder) use ($search): void {
$builder
->where('identifier', 'like', "%{$search}%")
->orWhere('ip', 'like', "%{$search}%")
->orWhere('reason', 'like', "%{$search}%")
->orWhereHas('user', function ($userQuery) use ($search): void {
$userQuery
->where('name', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
});
}
if ($eventType !== '' && $eventType !== 'all') {
$query->where('event_type', $eventType);
}
if ($status !== '' && $status !== 'all') {
$query->where('status', $status);
}
$logs = $query->paginate(50)->withQueryString()->through(function (AuthAuditLog $log): array {
return [
'id' => $log->id,
'event_type' => $log->event_type,
'identifier' => $log->identifier,
'status' => $log->status,
'reason' => $log->reason,
'ip' => $log->ip,
'user_agent' => $log->user_agent,
'metadata' => $log->metadata ?? [],
'created_at' => $log->created_at,
'user' => $log->user ? [
'id' => $log->user->id,
'name' => $log->user->name,
'username' => $log->user->username,
'email' => $log->user->email,
'role' => $log->user->role,
] : null,
];
});
return Inertia::render('Admin/AuthAudit', [
'logs' => $logs,
'filters' => [
'search' => $search,
'event' => $eventType,
'status' => $status,
],
'eventOptions' => [
['value' => 'all', 'label' => 'All events'],
['value' => 'login', 'label' => 'Login'],
['value' => 'register', 'label' => 'Register'],
['value' => 'forgot_password', 'label' => 'Forgot password'],
['value' => 'reset_password', 'label' => 'Reset password'],
],
'statusOptions' => [
['value' => 'all', 'label' => 'All statuses'],
['value' => 'success', 'label' => 'Success'],
['value' => 'failed', 'label' => 'Failed'],
],
]);
}
}

View File

@@ -30,12 +30,7 @@ class PostTrendingFeedController extends Controller
$result = $this->trendingService->getTrending($viewer, $page);
$formatted = array_map(
fn ($post) => $this->feedService->formatPost($post, $viewer),
$result['data'],
);
return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]);
return response()->json($result);
}
public function hashtag(Request $request, string $tag): JsonResponse

View File

@@ -13,9 +13,11 @@ use App\Support\UsernamePolicy;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\Cursor;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use UnexpectedValueException;
/**
* ProfileApiController
@@ -59,8 +61,23 @@ final class ProfileApiController extends Controller
$query = $this->applyArtworkSort($query, $sort);
$perPage = 24;
$paginator = $query->cursorPaginate($perPage);
$perPage = 24;
$cursor = Cursor::fromEncoded($request->input('cursor'));
try {
$paginator = (clone $query)->cursorPaginate($perPage, ['*'], 'cursor', $cursor);
} catch (UnexpectedValueException) {
$originalCursor = $request->query('cursor');
$request->query->remove('cursor');
try {
$paginator = (clone $query)->cursorPaginate($perPage, ['*'], 'cursor', null);
} finally {
if ($originalCursor !== null) {
$request->query->set('cursor', $originalCursor);
}
}
}
$data = collect($paginator->items())
->map(fn (Artwork $art) => $this->mapArtworkCardPayload($art))
@@ -196,14 +213,15 @@ final class ProfileApiController extends Controller
return $query
->leftJoin('artwork_stats as profile_artwork_stats', 'profile_artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->orderByDesc($statsColumn)
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id');
->selectRaw('COALESCE(' . $statsColumn . ', 0) as cursor_sort_value')
->orderByDesc('cursor_sort_value')
->orderByDesc('published_at')
->orderByDesc('id');
}
return $query
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id');
->orderByDesc('published_at')
->orderByDesc('id');
}
/**

View File

@@ -30,15 +30,14 @@ final class UploadVisionSuggestController extends Controller
public function __invoke(int $id, Request $request): JsonResponse
{
if (! $this->vision->isEnabled()) {
return response()->json(['tags' => [], 'vision_enabled' => false]);
}
$artwork = Artwork::query()->findOrFail($id);
$this->authorizeOrNotFound($request->user(), $artwork);
$limit = (int) $request->query('limit', 10);
return response()->json($this->vision->suggestTags($artwork, $this->normalizer, $limit));
return response()->json([
'tags' => [],
'vision_enabled' => false,
'reason' => 'disabled',
]);
}
private function authorizeOrNotFound(mixed $user, Artwork $artwork): void

View File

@@ -200,7 +200,7 @@ final class ArtworkDownloadController extends Controller
$host = preg_replace('/^www\./', '', $host) ?? '';
if ($host === '' || in_array($host, ['localhost', '127.0.0.1'], true) || str_ends_with($host, '.test')) {
return 'skinbase.top';
return 'skinbase.org';
}
return $host;

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Services\Auth\AuthAuditLogger;
use App\Services\Security\CaptchaVerifier;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -17,6 +18,7 @@ class AuthenticatedSessionController extends Controller
*/
public function __construct(
private readonly CaptchaVerifier $captchaVerifier,
private readonly AuthAuditLogger $authAuditLogger,
) {
}
@@ -35,9 +37,22 @@ class AuthenticatedSessionController extends Controller
{
$request->authenticate();
$user = $request->authenticatedUser();
$this->authAuditLogger->log(
eventType: 'login',
request: $request,
status: 'success',
identifier: (string) $request->input('email'),
user: $user,
metadata: [
'via' => $request->authenticatedViaUsername() ? 'username' : 'email',
'remember' => $request->boolean('remember'),
],
);
$request->session()->regenerate();
$user = $request->authenticatedUser();
if ($user && $request->authenticatedViaUsername() && ! $user->hasCompletedOnboarding()) {
$request->session()->put('username_login_upgrade', true);

View File

@@ -4,17 +4,24 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Auth\AuthAuditLogger;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class NewPasswordController extends Controller
{
public function __construct(
private readonly AuthAuditLogger $authAuditLogger,
) {
}
/**
* Display the password reset view.
*/
@@ -30,17 +37,36 @@ class NewPasswordController extends Controller
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
$validator = Validator::make($request->all(), [
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
if ($validator->fails()) {
$this->authAuditLogger->log(
eventType: 'reset_password',
request: $request,
status: 'failed',
reason: 'validation_failed',
identifier: (string) $request->input('email'),
metadata: ['fields' => array_keys($validator->errors()->toArray())],
);
$validator->validate();
}
$validated = $validator->validated();
$email = strtolower(trim((string) $validated['email']));
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
[
'email' => $email,
'password' => (string) $validated['password'],
'password_confirmation' => (string) $request->input('password_confirmation'),
'token' => (string) $validated['token'],
],
function (User $user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
@@ -51,12 +77,20 @@ class NewPasswordController extends Controller
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $status == Password::PASSWORD_RESET
$success = $status === Password::PASSWORD_RESET;
$this->authAuditLogger->log(
eventType: 'reset_password',
request: $request,
status: $success ? 'success' : 'failed',
reason: strtolower((string) $status),
identifier: $email,
user: $user,
);
return $success
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
: back()->withInput(['email' => $email])
->withErrors(['email' => __($status)]);
}
}

View File

@@ -3,13 +3,21 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Auth\AuthAuditLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
{
public function __construct(
private readonly AuthAuditLogger $authAuditLogger,
) {
}
/**
* Display the password reset link request view.
*/
@@ -25,20 +33,45 @@ class PasswordResetLinkController extends Controller
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
$validator = Validator::make($request->all(), [
'email' => ['required', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
if ($validator->fails()) {
$this->authAuditLogger->log(
eventType: 'forgot_password',
request: $request,
status: 'failed',
reason: 'validation_failed',
identifier: (string) $request->input('email'),
metadata: ['fields' => array_keys($validator->errors()->toArray())],
);
$validator->validate();
}
$validated = $validator->validated();
$email = strtolower(trim((string) $validated['email']));
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
$status = Password::sendResetLink(
$request->only('email')
['email' => $email]
);
return $status == Password::RESET_LINK_SENT
$success = $status === Password::RESET_LINK_SENT;
$this->authAuditLogger->log(
eventType: 'forgot_password',
request: $request,
status: $success ? 'success' : 'failed',
reason: strtolower((string) $status),
identifier: $email,
user: $user,
);
return $success
? back()->with('status', __($status))
: back()->withInput($request->only('email'))
: back()->withInput(['email' => $email])
->withErrors(['email' => __($status)]);
}
}

View File

@@ -6,6 +6,7 @@ use App\Jobs\SendVerificationEmailJob;
use App\Http\Controllers\Controller;
use App\Models\EmailSendEvent;
use App\Models\User;
use App\Services\Auth\AuthAuditLogger;
use App\Services\Auth\DisposableEmailService;
use App\Services\Auth\RegistrationVerificationTokenService;
use App\Services\Security\CaptchaVerifier;
@@ -15,6 +16,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\View\View;
@@ -25,6 +27,7 @@ class RegisteredUserController extends Controller
private readonly TurnstileVerifier $turnstileVerifier,
private readonly DisposableEmailService $disposableEmailService,
private readonly RegistrationVerificationTokenService $verificationTokenService,
private readonly AuthAuditLogger $authAuditLogger,
)
{
}
@@ -65,7 +68,22 @@ class RegisteredUserController extends Controller
];
$rules[$this->captchaVerifier->inputName()] = ['nullable', 'string'];
$validated = $request->validate($rules);
$validator = Validator::make($request->all(), $rules);
if ($validator->fails()) {
$this->authAuditLogger->log(
eventType: 'register',
request: $request,
status: 'failed',
reason: 'validation_failed',
identifier: (string) $request->input('email'),
metadata: ['fields' => array_keys($validator->errors()->toArray())],
);
$validator->validate();
}
$validated = $validator->validated();
$email = strtolower(trim((string) $validated['email']));
$ip = $request->ip();
@@ -86,6 +104,14 @@ class RegisteredUserController extends Controller
}
if (! $verified) {
$this->authAuditLogger->log(
eventType: 'register',
request: $request,
status: 'failed',
reason: 'captcha_failed',
identifier: $email,
);
return back()
->withInput($request->except('website'))
->withErrors(['captcha' => 'Captcha verification failed. Please try again.']);
@@ -94,6 +120,13 @@ class RegisteredUserController extends Controller
if ($this->disposableEmailService->isDisposableEmail($email)) {
$this->logEmailEvent($email, $ip, null, 'blocked', 'disposable');
$this->authAuditLogger->log(
eventType: 'register',
request: $request,
status: 'failed',
reason: 'disposable_email',
identifier: $email,
);
return back()
->withInput($request->except('website'))
@@ -103,6 +136,15 @@ class RegisteredUserController extends Controller
$user = User::query()->where('email', $email)->first();
if ($user && $user->hasCompletedOnboarding()) {
$this->authAuditLogger->log(
eventType: 'register',
request: $request,
status: 'failed',
reason: 'email_exists',
identifier: $email,
user: $user,
);
return back()
->withInput($request->except('website'))
->withErrors(['email' => 'An account with this email already exists.']);
@@ -136,6 +178,15 @@ class RegisteredUserController extends Controller
Auth::login($user);
$this->authAuditLogger->log(
eventType: 'register',
request: $request,
status: 'success',
reason: $user->wasRecentlyCreated ? 'user_created' : 'resume_onboarding',
identifier: $email,
user: $user,
);
$needsPasswordSetup = strtolower((string) ($user->onboarding_step ?? '')) !== 'password'
|| (bool) $user->needs_password_reset;

View File

@@ -194,7 +194,9 @@ class NewsController extends Controller
$userId = Auth::id();
$session = 'news_view_' . $article->id;
if ($request->session()->has($session)) {
$canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped');
if ($canReadSession && $request->session()->has($session)) {
return;
}
@@ -207,7 +209,9 @@ class NewsController extends Controller
$article->incrementViews();
$request->session()->put($session, true);
if ($canReadSession) {
$request->session()->put($session, true);
}
}
private function sidebarData(): array

View File

@@ -108,7 +108,7 @@ class CollectionInsightsController extends Controller
'bulkActions' => route('settings.collections.bulk-actions'),
],
'seo' => [
'title' => 'Collections Dashboard — Skinbase Nova',
'title' => 'Collections Dashboard — Skinbase',
'description' => 'Overview of collection lifecycle, quality, activity, and upcoming collection campaigns.',
'canonical' => route('settings.collections.dashboard'),
'robots' => 'noindex,follow',
@@ -127,7 +127,7 @@ class CollectionInsightsController extends Controller
'historyUrl' => route('settings.collections.history', ['collection' => $collection->id]),
'dashboardUrl' => route('settings.collections.dashboard'),
'seo' => [
'title' => sprintf('%s Analytics — Skinbase Nova', $collection->title),
'title' => sprintf('%s Analytics — Skinbase', $collection->title),
'description' => sprintf('Analytics and performance history for the %s collection.', $collection->title),
'canonical' => route('settings.collections.analytics', ['collection' => $collection->id]),
'robots' => 'noindex,follow',
@@ -150,7 +150,7 @@ class CollectionInsightsController extends Controller
'analyticsUrl' => route('settings.collections.analytics', ['collection' => $collection->id]),
'restorePattern' => route('settings.collections.history.restore', ['collection' => $collection->id, 'history' => '__HISTORY__']),
'seo' => [
'title' => sprintf('%s History — Skinbase Nova', $collection->title),
'title' => sprintf('%s History — Skinbase', $collection->title),
'description' => sprintf('Audit history and lifecycle changes for the %s collection.', $collection->title),
'canonical' => route('settings.collections.history', ['collection' => $collection->id]),
'robots' => 'noindex,follow',

View File

@@ -92,7 +92,7 @@ class CollectionProgrammingController extends Controller
'surfaces' => route('settings.collections.surfaces.index'),
],
'seo' => [
'title' => 'Collection Programming — Skinbase Nova',
'title' => 'Collection Programming — Skinbase',
'description' => 'Staff programming tools for assignments, previews, eligibility diagnostics, and recommendation refreshes.',
'canonical' => route('staff.collections.programming'),
'robots' => 'noindex,follow',

View File

@@ -66,7 +66,7 @@ class CollectionSurfaceController extends Controller
'batchEditorial' => route('settings.collections.surfaces.batch-editorial'),
],
'seo' => [
'title' => 'Collection Surfaces - Skinbase Nova',
'title' => 'Collection Surfaces - Skinbase',
'description' => 'Staff tools for homepage, discovery, and campaign collection surfaces.',
'canonical' => route('settings.collections.surfaces.index'),
'robots' => 'noindex,follow',

View File

@@ -43,7 +43,7 @@ class FeaturedArtworkAdminController extends Controller
'forceHeroEnabled' => $this->hasForceHeroColumn(),
],
'seo' => [
'title' => 'Featured Artworks — Skinbase Nova',
'title' => 'Featured Artworks — Skinbase',
'description' => 'Editorial controls for homepage featured artworks and the current hero winner.',
'canonical' => route($routePrefix . 'main'),
'robots' => 'noindex,follow',

View File

@@ -198,6 +198,14 @@ class HomepageAnnouncementController extends Controller
return;
}
$backgroundDisk = $this->announcements->backgroundImageDisk();
if (Storage::disk($backgroundDisk)->exists($path)) {
Storage::disk($backgroundDisk)->delete($path);
return;
}
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
@@ -268,8 +276,8 @@ class HomepageAnnouncementController extends Controller
]);
}
$storedPath = 'homepage-announcements/' . pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME) . '.webp';
Storage::disk('public')->put($storedPath, $webpBinary, ['visibility' => 'public']);
$storedPath = $this->announcements->backgroundImagePrefix() . '/' . pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME) . '.webp';
Storage::disk($this->announcements->backgroundImageDisk())->put($storedPath, $webpBinary, ['visibility' => 'public']);
} finally {
imagedestroy($image);
}

View File

@@ -40,7 +40,7 @@ final class StudioWorldController extends Controller
return Inertia::render('Studio/StudioWorldsIndex', [
'title' => 'Worlds',
'description' => 'Create and manage seasonal, event, and campaign destinations across Skinbase Nova.',
'description' => 'Create and manage seasonal, event, and campaign destinations across Skinbase.',
'listing' => $this->worlds->studioListing($request->only(['q', 'status', 'type', 'per_page'])),
'analytics' => $this->analytics->portfolioReport(),
'statusOptions' => [
@@ -435,7 +435,7 @@ final class StudioWorldController extends Controller
$payload = $this->worlds->publicShowPayload($world, $request->user(), true);
$seo = app(SeoFactory::class)->collectionPage(
$world->seo_title ?: ($world->title . ' — Skinbase Nova Preview'),
$world->seo_title ?: ($world->title . ' — Skinbase Preview'),
$world->seo_description ?: ($world->summary ?: $world->description ?: 'Preview world page'),
route('studio.worlds.preview', ['world' => $world]),
$world->ogImageUrl(),

View File

@@ -116,9 +116,9 @@ class ProfileCollectionController extends Controller
$seo = app(SeoFactory::class)->collectionPage(
$collection->is_featured
? sprintf('Featured: %s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName())
: sprintf('%s by %s — Skinbase Nova', $collection->title, $collection->displayOwnerName()),
$collection->summary ?: $collection->description ?: sprintf('Explore the %s collection by %s on Skinbase Nova.', $collection->title, $collection->displayOwnerName()),
? sprintf('Featured: %s by %s — Skinbase', $collection->title, $collection->displayOwnerName())
: sprintf('%s by %s — Skinbase', $collection->title, $collection->displayOwnerName()),
$collection->summary ?: $collection->description ?: sprintf('Explore the %s collection by %s on Skinbase.', $collection->title, $collection->displayOwnerName()),
$collectionPayload['public_url'],
$collectionPayload['cover_image'],
$collection->visibility === Collection::VISIBILITY_PUBLIC,
@@ -202,8 +202,8 @@ class ProfileCollectionController extends Controller
$seriesDescription = $seriesMeta['description'];
$seo = app(SeoFactory::class)->collectionListing(
sprintf('Series: %s — Skinbase Nova', $seriesKey),
sprintf('Explore the %s collection series on Skinbase Nova.', $seriesKey),
sprintf('Series: %s — Skinbase', $seriesKey),
sprintf('Explore the %s collection series on Skinbase.', $seriesKey),
route('collections.series.show', ['seriesKey' => $seriesKey])
)->toArray();

View File

@@ -155,8 +155,8 @@ class SavedCollectionController extends Controller
'libraryUrl' => route('me.saved.collections'),
'browseUrl' => route('collections.featured'),
'seo' => [
'title' => $activeList ? sprintf('%s — Saved Collections — Skinbase Nova', $activeList->title) : 'Saved Collections — Skinbase Nova',
'description' => $activeList ? sprintf('Saved collections in the %s list on Skinbase Nova.', $activeList->title) : 'Your saved collections on Skinbase Nova.',
'title' => $activeList ? sprintf('%s — Saved Collections — Skinbase', $activeList->title) : 'Saved Collections — Skinbase',
'description' => $activeList ? sprintf('Saved collections in the %s list on Skinbase.', $activeList->title) : 'Your saved collections on Skinbase.',
'canonical' => $activeList ? route('me.saved.collections.lists.show', ['listSlug' => $activeList->slug]) : route('me.saved.collections'),
'robots' => 'noindex,follow',
],

View File

@@ -18,7 +18,7 @@ final class AccountHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Account Settings Help — Skinbase',
'Learn how account settings, profile settings, email changes, password care, and creator preferences work on Skinbase Nova.',
'Learn how account settings, profile settings, email changes, password care, and creator preferences work on Skinbase.',
$canonical,
)
->toArray();

View File

@@ -18,6 +18,7 @@ use App\Services\Maturity\ArtworkMaturityService;
use App\Support\Seo\SeoFactory;
use App\Support\AvatarUrl;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@@ -104,6 +105,8 @@ final class ArtworkPageController extends Controller
->published()
->firstOrFail();
$this->loadCategoryAncestors($artwork->categories);
$canonicalSlug = Str::slug((string) ($artwork->slug ?: $artwork->title));
if ($canonicalSlug === '') {
$canonicalSlug = (string) $artwork->id;
@@ -203,10 +206,25 @@ final class ArtworkPageController extends Controller
->values()
->all();
// Recursive helper to format a comment and its nested replies
$approvedComments = ArtworkComment::query()
->with('user.profile')
->where('artwork_id', $artwork->id)
->where('is_approved', true)
->orderBy('created_at')
->limit(500)
->get();
$commentsByParent = $approvedComments->groupBy(
static fn (ArtworkComment $comment): string => $comment->parent_id === null
? 'root'
: (string) $comment->parent_id
);
// Recursive helper to format a comment and its nested replies.
$formatComment = null;
$formatComment = function (ArtworkComment $c) use (&$formatComment): array {
$replies = $c->relationLoaded('approvedReplies') ? $c->approvedReplies : collect();
$formatComment = function (ArtworkComment $c) use (&$formatComment, $commentsByParent): array {
/** @var Collection<int, ArtworkComment> $replies */
$replies = $commentsByParent->get((string) $c->id, collect());
$user = $c->user;
$userId = (int) ($c->user_id ?? 0);
$avatarHash = $user?->profile?->avatar_hash ?? null;
@@ -234,7 +252,9 @@ final class ArtworkPageController extends Controller
'username' => $user?->username,
'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : ($userId > 0 ? '/profile/' . $userId : null),
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
'avatar_url' => $avatarHash !== null
? AvatarUrl::forUser($userId, $avatarHash, 64)
: AvatarUrl::default(),
'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'),
],
@@ -242,13 +262,8 @@ final class ArtworkPageController extends Controller
];
};
$comments = ArtworkComment::with(['user.profile', 'approvedReplies'])
->where('artwork_id', $artwork->id)
->where('is_approved', true)
->whereNull('parent_id')
->orderBy('created_at')
->limit(500)
->get()
$comments = $commentsByParent
->get('root', collect())
->map($formatComment)
->values()
->all();
@@ -314,6 +329,41 @@ final class ArtworkPageController extends Controller
return $totals;
}
private function loadCategoryAncestors(Collection $categories): void
{
$currentLevel = $categories->filter();
while ($currentLevel->isNotEmpty()) {
$fetchedParents = collect();
$missingParentIds = $currentLevel
->filter(static fn ($category) => $category->parent_id !== null && ! $category->relationLoaded('parent'))
->pluck('parent_id')
->filter()
->unique()
->values();
if ($missingParentIds->isNotEmpty()) {
$fetchedParents = \App\Models\Category::query()
->with('contentType')
->whereIn('id', $missingParentIds->all())
->get()
->keyBy('id');
$currentLevel->each(function ($category) use ($fetchedParents): void {
if ($category->parent_id !== null && ! $category->relationLoaded('parent')) {
$category->setRelation('parent', $fetchedParents->get($category->parent_id));
}
});
}
$currentLevel = $currentLevel
->map(static fn ($category) => $category->relationLoaded('parent') ? $category->getRelation('parent') : null)
->filter()
->unique('id')
->values();
}
}
/** Silently catch suggestion query failures so error page never crashes. */
private function safeSuggestions(callable $fn): mixed
{

View File

@@ -18,7 +18,7 @@ final class AuthHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Signup and Login Help — Skinbase',
'Learn how signup, login, password recovery, verification, and account access work on Skinbase Nova, with clear guidance for common access problems and practical next steps.',
'Learn how signup, login, password recovery, verification, and account access work on Skinbase, with clear guidance for common access problems and practical next steps.',
$canonical,
)
->toArray();
@@ -27,7 +27,7 @@ final class AuthHelpPageController extends Controller
return Inertia::render('Help/AuthHelpPage', [
'title' => 'Signup & Login Help',
'description' => 'Get clear help for account creation, sign-in, password recovery, verification basics, and common access problems on Skinbase Nova.',
'description' => 'Get clear help for account creation, sign-in, password recovery, verification basics, and common access problems on Skinbase.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),

View File

@@ -148,7 +148,12 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
$ttl = self::SORT_TTL_MAP[$sort] ?? 300;
$mainCategories = $this->mainCategories();
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
$rootCategories = $contentType->rootCategories()
->with('contentType')
->orderBy('sort_order')
->orderBy('name')
->get();
$rootCategoryLinks = $this->buildCategoryLinkItems($rootCategories, $contentSlug);
$normalizedPath = trim((string) $path, '/');
if ($normalizedPath === '') {
@@ -160,13 +165,14 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'],
], $perPage, false, $page)
);
$this->loadGalleryArtworkRelations($artworks->getCollection());
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug), $artworks);
return view('gallery.index', [
'gallery_type' => 'content-type',
'mainCategories' => $mainCategories,
'subcategories' => $rootCategories,
'subcategories' => $rootCategoryLinks,
'contentType' => $contentType,
'category' => null,
'artworks' => $artworks,
@@ -178,7 +184,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
(object) ['name' => 'Explore', 'url' => '/browse'],
(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug],
]),
'page_title' => $contentType->name . ' Skinbase Nova',
'page_title' => $contentType->name . ' Skinbase',
'page_meta_description' => $contentType->description ?? ('Discover the best ' . $contentType->name . ' artworks on Skinbase'),
'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'],
@@ -194,6 +200,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
abort(404);
}
$this->loadCategoryLineage($category);
$categorySlugs = $this->categoryFilterSlugs($category);
$filterExpression = $this->categoryPageFilterExpression($contentSlug, $categorySlugs);
@@ -205,14 +213,25 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'sort' => self::SORT_MAP[$sort] ?? ['published_at_ts:desc'],
], $perPage, false, $page)
);
$this->loadGalleryArtworkRelations($artworks->getCollection());
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
$seo = $this->buildPaginationSeo($request, url('/' . $contentSlug . '/' . strtolower($category->full_slug_path)), $artworks);
$navigationCategory = $category->parent ?: $category;
$navigationPath = strtolower($navigationCategory->full_slug_path);
$subcategoryParent = (object) [
'id' => $navigationCategory->id,
'url' => $this->buildCategoryUrl($contentSlug, $navigationPath),
];
$subcategories = $navigationCategory->children()->orderBy('sort_order')->orderBy('name')->get();
$subcategories = $navigationCategory->children()
->with(['contentType', 'parent.contentType'])
->orderBy('sort_order')
->orderBy('name')
->get();
$subcategoryLinks = $this->buildCategoryLinkItems($subcategories, $contentSlug, $navigationPath);
if ($subcategories->isEmpty()) {
$subcategories = $rootCategories;
$subcategoryLinks = $rootCategoryLinks;
}
$breadcrumbs = collect(array_merge([
@@ -235,8 +254,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
return view('gallery.index', [
'gallery_type' => 'category',
'mainCategories' => $mainCategories,
'subcategories' => $subcategories,
'subcategory_parent' => $navigationCategory,
'subcategories' => $subcategoryLinks,
'subcategory_parent' => $subcategoryParent,
'contentType' => $contentType,
'category' => $category,
'artworks' => $artworks,
@@ -245,7 +264,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'hero_title' => $category->name,
'hero_description' => $category->description ?? ($contentType->name . ' artworks on Skinbase.'),
'breadcrumbs' => $breadcrumbs,
'page_title' => $category->name . ' Skinbase Nova',
'page_title' => $category->name . ' Skinbase',
'page_meta_description' => $category->description ?? ('Discover the best ' . $category->name . ' ' . $contentType->name . ' artworks on Skinbase'),
'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'],
@@ -303,13 +322,12 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
$present = ThumbnailPresenter::present($artwork, 'md');
$group = $artwork->group;
$isGroupPublisher = $group !== null;
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
$avatarUrl = $isGroupPublisher
? $group->avatarUrl()
: \App\Support\AvatarUrl::forUser(
(int) ($artwork->user_id ?? 0),
$artwork->user?->profile?->avatar_hash ?? null,
64
);
: ($avatarHash !== null
? \App\Support\AvatarUrl::forUser((int) ($artwork->user_id ?? 0), $avatarHash, 64)
: \App\Support\AvatarUrl::default());
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
@@ -349,27 +367,74 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
*/
private function categoryFilterSlugs(Category $category): array
{
$category->loadMissing('descendants');
$slugs = [];
$stack = [$category];
$pendingParentIds = [$category->id];
while ($stack !== []) {
/** @var Category $current */
$current = array_pop($stack);
if (! empty($current->slug)) {
$slugs[] = Str::lower($current->slug);
}
if (! empty($category->slug)) {
$slugs[] = Str::lower($category->slug);
}
foreach ($current->children as $child) {
$child->loadMissing('descendants');
$stack[] = $child;
while ($pendingParentIds !== []) {
$children = Category::query()
->whereIn('parent_id', $pendingParentIds)
->get(['id', 'slug']);
$pendingParentIds = $children->pluck('id')->all();
foreach ($children as $child) {
if (! empty($child->slug)) {
$slugs[] = Str::lower($child->slug);
}
}
}
return array_values(array_unique($slugs));
}
private function loadCategoryLineage(Category $category): void
{
$current = $category;
while ($current !== null) {
$current->loadMissing(['contentType', 'parent']);
$current = $current->parent;
}
}
private function buildCategoryLinkItems(Collection $categories, string $contentSlug, ?string $basePath = null): Collection
{
$normalizedBasePath = trim(strtolower((string) $basePath), '/');
return $categories->map(function (Category $category) use ($contentSlug, $normalizedBasePath) {
return (object) [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
'url' => $this->buildCategoryUrl($contentSlug, implode('/', array_filter([$normalizedBasePath, $category->slug]))),
];
});
}
private function buildCategoryUrl(string $contentSlug, ?string $path = null): string
{
$normalizedPath = trim(strtolower((string) $path), '/');
return '/' . implode('/', array_filter([$contentSlug, $normalizedPath]));
}
private function loadGalleryArtworkRelations(Collection $artworks): void
{
if ($artworks->isEmpty()) {
return;
}
$artworks->loadMissing([
'user.profile',
'group',
'categories.contentType',
]);
}
private function categoryFilterClause(string $categorySlug): string
{
$quoted = addslashes($categorySlug);

View File

@@ -18,7 +18,7 @@ final class CardsHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Cards Help — Skinbase',
'Learn what Cards are on Skinbase Nova, how they differ from artworks, posts, and collections, and how to create, publish, and use them effectively in personal and Group workflows.',
'Learn what Cards are on Skinbase, how they differ from artworks, posts, and collections, and how to create, publish, and use them effectively in personal and Group workflows.',
$canonical,
)
->toArray();
@@ -27,7 +27,7 @@ final class CardsHelpPageController extends Controller
return Inertia::render('Help/CardsHelpPage', [
'title' => 'Cards Help',
'description' => 'Understand Cards as a distinct creative format on Skinbase Nova, with guidance for creation, publishing, ownership, design quality, and real-world use cases.',
'description' => 'Understand Cards as a distinct creative format on Skinbase, with guidance for creation, publishing, ownership, design quality, and real-world use cases.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),

View File

@@ -52,9 +52,9 @@ class CollectionDiscoveryController extends Controller
$results = $this->search->publicSearch($filters, (int) config('collections.v5.search.public_per_page', 18));
$seo = app(SeoFactory::class)->collectionListing(
'Search Collections — Skinbase Nova',
'Search Collections — Skinbase',
filled($filters['q'] ?? null)
? sprintf('Search results for "%s" across public Skinbase Nova collections.', $filters['q'])
? sprintf('Search results for "%s" across public Skinbase collections.', $filters['q'])
: 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.',
$request->fullUrl(),
null,
@@ -65,7 +65,7 @@ class CollectionDiscoveryController extends Controller
'eyebrow' => 'Search',
'title' => 'Search collections',
'description' => filled($filters['q'] ?? null)
? sprintf('Search results for "%s" across public Skinbase Nova collections.', $filters['q'])
? sprintf('Search results for "%s" across public Skinbase collections.', $filters['q'])
: 'Browse public collections using filters for category, style, theme, color, quality tier, freshness, and programming metadata.',
'seo' => $seo,
'collections' => $this->collections->mapCollectionCardPayloads($results->items(), false, $request->user()),
@@ -100,7 +100,7 @@ class CollectionDiscoveryController extends Controller
viewer: $request->user(),
eyebrow: 'Discovery',
title: 'Featured collections',
description: 'A rotating set of standout galleries from across Skinbase Nova. Some are meticulously hand-sequenced. Others are smart collections that stay fresh as the creator publishes new work.',
description: 'A rotating set of standout galleries from across Skinbase. Some are meticulously hand-sequenced. Others are smart collections that stay fresh as the creator publishes new work.',
collections: $featuredCollections->isNotEmpty() ? $featuredCollections : $this->discovery->publicFeaturedCollections((int) config('collections.discovery.featured_limit', 18)),
communityCollections: $this->discovery->publicCollectionsByType(Collection::TYPE_COMMUNITY, 6),
editorialCollections: $this->discovery->publicCollectionsByType(Collection::TYPE_EDITORIAL, 6),
@@ -204,7 +204,7 @@ class CollectionDiscoveryController extends Controller
abort_if(! $program || collect($landing['collections'])->isEmpty(), 404);
$seo = app(SeoFactory::class)->collectionListing(
sprintf('%s — Skinbase Nova', $program['label']),
sprintf('%s — Skinbase', $program['label']),
$program['description'],
route('collections.program.show', ['programKey' => $program['key']]),
)->toArray();
@@ -239,7 +239,7 @@ class CollectionDiscoveryController extends Controller
$campaign = null,
) {
$seo = app(SeoFactory::class)->collectionListing(
sprintf('%s — Skinbase Nova', $title),
sprintf('%s — Skinbase', $title),
$description,
url()->current(),
)->toArray();

View File

@@ -92,12 +92,17 @@ final class ExploreController extends Controller
$artworks = $this->filterBrowsableArtworks($artworks);
// EGS: fill grid to minimum when uploads are sparse
$artworks = $this->gridFiller->fill($artworks, 0, $page);
$this->loadPresentationRelations($artworks->getCollection());
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
// EGS §11: featured spotlight row on page 1 only
$spotlightItems = ($page === 1 && EarlyGrowth::spotlightEnabled())
? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a))
: collect();
$spotlightItems = collect();
if ($page === 1 && EarlyGrowth::spotlightEnabled()) {
$spotlightItems = $this->spotlight->getSpotlight(6);
$this->loadPresentationRelations($spotlightItems);
$spotlightItems = $spotlightItems->map(fn ($a) => $this->presentArtwork($a));
}
$mainCategories = $this->mainCategories();
$seo = $this->paginationSeo($request, url('/explore'), $artworks);
@@ -165,12 +170,17 @@ final class ExploreController extends Controller
$artworks = $this->filterBrowsableArtworks($artworks);
// EGS: fill grid to minimum when uploads are sparse
$artworks = $this->gridFiller->fill($artworks, 0, $page);
$this->loadPresentationRelations($artworks->getCollection());
$artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a));
// EGS §11: featured spotlight row on page 1 only
$spotlightItems = ($page === 1 && EarlyGrowth::spotlightEnabled())
? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a))
: collect();
$spotlightItems = collect();
if ($page === 1 && EarlyGrowth::spotlightEnabled()) {
$spotlightItems = $this->spotlight->getSpotlight(6);
$this->loadPresentationRelations($spotlightItems);
$spotlightItems = $spotlightItems->map(fn ($a) => $this->presentArtwork($a));
}
$mainCategories = $this->mainCategories();
$contentType = null;
@@ -557,6 +567,13 @@ final class ExploreController extends Controller
], $artwork, request()->user());
}
private function loadPresentationRelations(mixed $artworks): void
{
if (is_object($artworks) && method_exists($artworks, 'loadMissing')) {
$artworks->loadMissing(['user.profile', 'group', 'categories.contentType']);
}
}
private function paginationSeo(Request $request, string $base, mixed $paginator): array
{
$q = $request->query();

View File

@@ -18,7 +18,7 @@ final class GroupFaqPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Groups FAQ — Skinbase',
'Fast answers to the most common Groups questions on Skinbase Nova, including roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting.',
'Fast answers to the most common Groups questions on Skinbase, including roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting.',
$canonical,
)
->toArray();
@@ -27,7 +27,7 @@ final class GroupFaqPageController extends Controller
return Inertia::render('Group/GroupFaqPage', [
'title' => 'Groups FAQ',
'description' => 'Quick answers about Groups, roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting on Skinbase Nova.',
'description' => 'Quick answers about Groups, roles, permissions, publishing, contributor credit, invites, workflows, and troubleshooting on Skinbase.',
'seo' => $seo,
'links' => [
'groups_directory' => route('groups.index'),

View File

@@ -18,7 +18,7 @@ final class GroupHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Groups Guide, Help, and Best Practices — Skinbase',
'Learn how Groups work on Skinbase Nova, how shared publishing preserves contributor credit, and how to manage roles, releases, reviews, projects, and team workflows with confidence.',
'Learn how Groups work on Skinbase, how shared publishing preserves contributor credit, and how to manage roles, releases, reviews, projects, and team workflows with confidence.',
$canonical,
)
->toArray();
@@ -27,7 +27,7 @@ final class GroupHelpPageController extends Controller
return Inertia::render('Group/GroupHelpPage', [
'title' => 'Groups Help & Guide',
'description' => 'Everything creators need to understand Groups, publish collaboratively, preserve contributor credit, and build a healthy shared identity on Skinbase Nova.',
'description' => 'Everything creators need to understand Groups, publish collaboratively, preserve contributor credit, and build a healthy shared identity on Skinbase.',
'seo' => $seo,
'links' => [
'groups_directory' => route('groups.index'),

View File

@@ -18,7 +18,7 @@ final class GroupQuickstartPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Groups Quickstart — Skinbase',
'A fast, creator-friendly Groups quickstart for Skinbase Nova. Learn when to use a Group, create one, invite members, and publish your first Group artwork with correct contributor credit.',
'A fast, creator-friendly Groups quickstart for Skinbase. Learn when to use a Group, create one, invite members, and publish your first Group artwork with correct contributor credit.',
$canonical,
)
->toArray();

View File

@@ -18,7 +18,7 @@ final class HelpCenterPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Help Center — Skinbase',
'Find help, guides, quickstarts, FAQs, and troubleshooting for Skinbase Nova, including Groups, Studio, Upload, Cards, Profile, and account access.',
'Find help, guides, quickstarts, FAQs, and troubleshooting for Skinbase, including Groups, Studio, Upload, Cards, Profile, and account access.',
$canonical,
)
->toArray();
@@ -27,7 +27,7 @@ final class HelpCenterPageController extends Controller
return Inertia::render('Help/HelpCenterPage', [
'title' => 'Help Center',
'description' => 'Find guides, quickstarts, FAQs, and troubleshooting for Skinbase Nova in one structured help hub.',
'description' => 'Find guides, quickstarts, FAQs, and troubleshooting for Skinbase in one structured help hub.',
'seo' => $seo,
'links' => [
'studio_help' => route('help.studio'),

View File

@@ -60,12 +60,12 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => 'Nova Cards - Skinbase Nova',
'description' => 'Browse featured, trending, and latest Nova Cards. Discover beautiful quote cards, mood cards, and visual text art by the Skinbase Nova community.',
'title' => 'Cards - Skinbase',
'description' => 'Browse featured, trending, and latest Cards. Discover beautiful quote cards, mood cards, and visual text art by the Skinbase community.',
'canonical' => route('cards.index'),
'robots' => 'index,follow',
],
'heading' => 'Nova Cards',
'heading' => 'Cards',
'subheading' => (string) config('nova_cards.brand.subtitle'),
'cards' => $this->presenter->cards($latest->items()),
'pagination' => $latest,
@@ -90,13 +90,13 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => $category->name . ' Cards - Skinbase Nova',
'description' => $category->description ?: ('Browse ' . strtolower($category->name) . ' Nova Cards on Skinbase Nova.'),
'title' => $category->name . ' Cards - Skinbase',
'description' => $category->description ?: ('Browse ' . strtolower($category->name) . ' Cards on Skinbase.'),
'canonical' => route('cards.category', ['categorySlug' => $category->slug]),
'robots' => 'index,follow',
],
'heading' => $category->name,
'subheading' => $category->description ?: 'Explore this Nova Cards category.',
'subheading' => $category->description ?: 'Explore this Cards category.',
'cards' => $this->presenter->cards($cards->items()),
'pagination' => $cards,
'featuredCards' => [],
@@ -119,8 +119,8 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => 'Popular Cards - Skinbase Nova',
'description' => 'Browse the most liked, saved, and viewed Nova Cards on Skinbase Nova.',
'title' => 'Popular Cards - Skinbase',
'description' => 'Browse the most liked, saved, and viewed Cards on Skinbase.',
'canonical' => route('cards.popular'),
'robots' => 'index,follow',
],
@@ -153,13 +153,13 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => 'Rising Cards - Skinbase Nova',
'description' => 'Discover Nova Cards that are gaining traction right now — fresh creators and fast-rising saves and remixes.',
'title' => 'Rising Cards - Skinbase',
'description' => 'Discover Cards that are gaining traction right now — fresh creators and fast-rising saves and remixes.',
'canonical' => route('cards.rising'),
'robots' => 'index,follow',
],
'heading' => 'Rising',
'subheading' => 'Fresh Nova Cards gaining momentum right now.',
'subheading' => 'Fresh Cards gaining momentum right now.',
'cards' => $this->presenter->cards($paginated->items(), false, $request->user()),
'pagination' => $paginated,
'featuredCards' => [],
@@ -182,13 +182,13 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => 'Remixed Cards - Skinbase Nova',
'description' => 'Discover Nova Cards remixed from community originals with attribution and lineage.',
'title' => 'Remixed Cards - Skinbase',
'description' => 'Discover Cards remixed from community originals with attribution and lineage.',
'canonical' => route('cards.remixed'),
'robots' => 'index,follow',
],
'heading' => 'Remixed cards',
'subheading' => 'Community reinterpretations linked back to their original Nova Cards.',
'subheading' => 'Community reinterpretations linked back to their original Cards.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards,
'featuredCards' => [],
@@ -214,8 +214,8 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => 'Best Remixes - Skinbase Nova',
'description' => 'Browse standout Nova Card remixes ranked by remix traction, saves, and likes.',
'title' => 'Best Remixes - Skinbase',
'description' => 'Browse standout Card remixes ranked by remix traction, saves, and likes.',
'canonical' => route('cards.remix-highlights'),
'robots' => 'index,follow',
],
@@ -295,13 +295,13 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => 'Editorial Picks - Nova Cards - Skinbase Nova',
'description' => 'Browse editorial Nova Cards picks, featured collections, and highlighted challenges.',
'title' => 'Editorial Picks - Cards - Skinbase',
'description' => 'Browse editorial Cards picks, featured collections, and highlighted challenges.',
'canonical' => route('cards.editorial'),
'robots' => 'index,follow',
],
'heading' => 'Editorial picks',
'subheading' => 'Curated Nova Cards, featured collections, and standout challenge surfaces chosen for quality and cohesion.',
'subheading' => 'Curated Cards, featured collections, and standout challenge surfaces chosen for quality and cohesion.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards,
'featuredCards' => [],
@@ -329,13 +329,13 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => 'Seasonal Cards - Nova Cards - Skinbase Nova',
'description' => 'Browse seasonal and event-aware Nova Cards grouped by recurring moods, holidays, and time-of-year themes.',
'title' => 'Seasonal Cards - Cards - Skinbase',
'description' => 'Browse seasonal and event-aware Cards grouped by recurring moods, holidays, and time-of-year themes.',
'canonical' => route('cards.seasonal'),
'robots' => 'index,follow',
],
'heading' => 'Seasonal cards',
'subheading' => 'Discover Nova Cards grouped by recurring seasonal and campaign-style themes.',
'subheading' => 'Discover Cards grouped by recurring seasonal and campaign-style themes.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards,
'featuredCards' => [],
@@ -363,13 +363,13 @@ class NovaCardsController extends Controller
return view('cards.challenges', [
'meta' => [
'title' => 'Card Challenges - Skinbase Nova',
'description' => 'Browse active and completed Nova Cards challenges, prompts, and winners.',
'title' => 'Card Challenges - Skinbase',
'description' => 'Browse active and completed Cards challenges, prompts, and winners.',
'canonical' => route('cards.challenges'),
'robots' => 'index,follow',
],
'heading' => 'Card challenges',
'subheading' => 'Official prompts and community challenge runs for Nova Cards creators.',
'subheading' => 'Official prompts and community challenge runs for Cards creators.',
'challenges' => $challenges,
]);
}
@@ -388,8 +388,8 @@ class NovaCardsController extends Controller
return view('cards.challenges', [
'meta' => [
'title' => $challenge->title . ' - Skinbase Nova',
'description' => $challenge->description ?: 'Browse entries for this Nova Cards challenge.',
'title' => $challenge->title . ' - Skinbase',
'description' => $challenge->description ?: 'Browse entries for this Cards challenge.',
'canonical' => route('cards.challenges.show', ['slug' => $challenge->slug]),
'robots' => 'index,follow',
],
@@ -410,8 +410,8 @@ class NovaCardsController extends Controller
{
return view('cards.resources', [
'meta' => [
'title' => 'Template Packs - Skinbase Nova',
'description' => 'Browse official Nova Cards template packs and starting points.',
'title' => 'Template Packs - Skinbase',
'description' => 'Browse official Cards template packs and starting points.',
'canonical' => route('cards.templates'),
'robots' => 'index,follow',
],
@@ -427,13 +427,13 @@ class NovaCardsController extends Controller
{
return view('cards.resources', [
'meta' => [
'title' => 'Asset Packs - Skinbase Nova',
'description' => 'Browse official Nova Cards asset packs for decorative and editorial layouts.',
'title' => 'Asset Packs - Skinbase',
'description' => 'Browse official Cards asset packs for decorative and editorial layouts.',
'canonical' => route('cards.assets'),
'robots' => 'index,follow',
],
'heading' => 'Asset packs',
'subheading' => 'Official decorative and editorial pack sets for the Nova Cards v2 editor.',
'subheading' => 'Official decorative and editorial pack sets for the Cards v2 editor.',
'packs' => collect($this->presenter->options()['asset_packs'] ?? []),
'templates' => collect(),
'resourceType' => 'asset',
@@ -447,8 +447,8 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => '#' . $tag->name . ' Cards - Skinbase Nova',
'description' => 'Browse Nova Cards tagged with #' . $tag->name . ' on Skinbase Nova.',
'title' => '#' . $tag->name . ' Cards - Skinbase',
'description' => 'Browse Cards tagged with #' . $tag->name . ' on Skinbase.',
'canonical' => route('cards.tag', ['tagSlug' => $tag->slug]),
'robots' => 'index,follow',
],
@@ -480,13 +480,13 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => $mood['label'] . ' Mood Cards - Skinbase Nova',
'description' => 'Browse Nova Cards grouped into the ' . strtolower((string) $mood['label']) . ' mood family on Skinbase Nova.',
'title' => $mood['label'] . ' Mood Cards - Skinbase',
'description' => 'Browse Cards grouped into the ' . strtolower((string) $mood['label']) . ' mood family on Skinbase.',
'canonical' => route('cards.mood', ['moodSlug' => $mood['key']]),
'robots' => 'index,follow',
],
'heading' => $mood['label'],
'subheading' => 'Discover Nova Cards grouped by a curated mood family using durable tag mappings.',
'subheading' => 'Discover Cards grouped by a curated mood family using durable tag mappings.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards,
'featuredCards' => [],
@@ -514,13 +514,13 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => $style['label'] . ' Style Cards - Skinbase Nova',
'description' => 'Browse Nova Cards using the ' . strtolower((string) $style['label']) . ' style family on Skinbase Nova.',
'title' => $style['label'] . ' Style Cards - Skinbase',
'description' => 'Browse Cards using the ' . strtolower((string) $style['label']) . ' style family on Skinbase.',
'canonical' => route('cards.style', ['styleSlug' => $style['key']]),
'robots' => 'index,follow',
],
'heading' => $style['label'],
'subheading' => 'Discover Nova Cards grouped by a shared visual style family.',
'subheading' => 'Discover Cards grouped by a shared visual style family.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards,
'featuredCards' => [],
@@ -548,13 +548,13 @@ class NovaCardsController extends Controller
return view('cards.index', [
'meta' => [
'title' => $palette['label'] . ' Palette Cards - Skinbase Nova',
'description' => 'Browse Nova Cards using the ' . strtolower((string) $palette['label']) . ' palette family on Skinbase Nova.',
'title' => $palette['label'] . ' Palette Cards - Skinbase',
'description' => 'Browse Cards using the ' . strtolower((string) $palette['label']) . ' palette family on Skinbase.',
'canonical' => route('cards.palette', ['paletteSlug' => $palette['key']]),
'robots' => 'index,follow',
],
'heading' => $palette['label'],
'subheading' => 'Discover Nova Cards grouped by shared palette families and color direction.',
'subheading' => 'Discover Cards grouped by shared palette families and color direction.',
'cards' => $this->presenter->cards($cards->items(), false, $request->user()),
'pagination' => $cards,
'featuredCards' => [],
@@ -580,8 +580,8 @@ class NovaCardsController extends Controller
return view('cards.index', array_merge($this->creatorPagePayload($request, $user), [
'meta' => [
'title' => '@' . $user->username . ' Cards - Skinbase Nova',
'description' => 'Browse Nova Cards created by @' . $user->username . ' on Skinbase Nova.',
'title' => '@' . $user->username . ' Cards - Skinbase',
'description' => 'Browse Cards created by @' . $user->username . ' on Skinbase.',
'canonical' => route('cards.creator', ['username' => strtolower((string) $user->username)]),
'robots' => 'index,follow',
],
@@ -602,13 +602,13 @@ class NovaCardsController extends Controller
return view('cards.index', array_merge($this->creatorPagePayload($request, $user), [
'meta' => [
'title' => '@' . $user->username . ' Portfolio - Skinbase Nova',
'description' => 'Browse the dedicated Nova Cards portfolio page for @' . $user->username . ' on Skinbase Nova.',
'title' => '@' . $user->username . ' Portfolio - Skinbase',
'description' => 'Browse the dedicated Cards portfolio page for @' . $user->username . ' on Skinbase.',
'canonical' => route('cards.creator.portfolio', ['username' => strtolower((string) $user->username)]),
'robots' => 'index,follow',
],
'heading' => '@' . $user->username . ' Portfolio',
'subheading' => 'A dedicated Nova Cards portfolio view for ' . ($user->name ?: '@' . $user->username) . '.',
'subheading' => 'A dedicated Cards portfolio view for ' . ($user->name ?: '@' . $user->username) . '.',
'context' => 'creator-portfolio',
]));
}
@@ -695,8 +695,8 @@ class NovaCardsController extends Controller
return view('cards.collection', [
'meta' => [
'title' => $collection->name . ' - Nova Cards Collection - Skinbase Nova',
'description' => $collection->description ?: 'Browse this curated Nova Cards collection.',
'title' => $collection->name . ' - Cards Collection - Skinbase',
'description' => $collection->description ?: 'Browse this curated Cards collection.',
'canonical' => route('cards.collections.show', ['slug' => $collection->slug, 'id' => $collection->id]),
'robots' => 'index,follow',
],
@@ -721,7 +721,7 @@ class NovaCardsController extends Controller
return view('cards.lineage', [
'meta' => [
'title' => $card->title . ' Lineage - Nova Cards - Skinbase Nova',
'title' => $card->title . ' Lineage - Cards - Skinbase',
'description' => 'Browse the remix lineage and related variants for this Nova Card.',
'canonical' => route('cards.lineage', ['slug' => $card->slug, 'id' => $card->id]),
'robots' => 'index,follow',
@@ -767,7 +767,7 @@ class NovaCardsController extends Controller
return view('cards.show', [
'card' => $this->presenter->card($card, true, $request->user()),
'meta' => [
'title' => $card->title . ' - Nova Cards - Skinbase Nova',
'title' => $card->title . ' - Cards - Skinbase',
'description' => $card->description ?: $card->quote_text,
'canonical' => route('cards.show', ['slug' => $card->slug, 'id' => $card->id]),
'robots' => $card->visibility === NovaCard::VISIBILITY_PUBLIC ? 'index,follow' : 'noindex,follow',

View File

@@ -18,7 +18,7 @@ final class ProfileHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Profile Help — Skinbase',
'Learn how profiles work on Skinbase Nova, how they differ from Groups, and how to build a stronger personal identity with better setup, presentation, and creator-facing profile habits.',
'Learn how profiles work on Skinbase, how they differ from Groups, and how to build a stronger personal identity with better setup, presentation, and creator-facing profile habits.',
$canonical,
)
->toArray();

View File

@@ -18,7 +18,7 @@ final class StudioHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Studio Help — Skinbase',
'Learn how Studio works on Skinbase Nova, including drafts, publishing, personal versus Group context, artworks, cards, collections, and collaboration workflows.',
'Learn how Studio works on Skinbase, including drafts, publishing, personal versus Group context, artworks, cards, collections, and collaboration workflows.',
$canonical,
)
->toArray();
@@ -27,7 +27,7 @@ final class StudioHelpPageController extends Controller
return Inertia::render('Help/StudioHelpPage', [
'title' => 'Studio Help',
'description' => 'Understand Studio as the creative control center of Skinbase Nova, with guidance for drafts, publishing, artworks, cards, collections, and Group workflows.',
'description' => 'Understand Studio as the creative control center of Skinbase, with guidance for drafts, publishing, artworks, cards, collections, and Group workflows.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),

View File

@@ -18,7 +18,7 @@ final class TroubleshootingHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Troubleshooting Help — Skinbase',
'Use fast, support-oriented troubleshooting guidance for login issues, permissions confusion, publishing blockers, profile setup problems, and bug-report escalation on Skinbase Nova.',
'Use fast, support-oriented troubleshooting guidance for login issues, permissions confusion, publishing blockers, profile setup problems, and bug-report escalation on Skinbase.',
$canonical,
)
->toArray();

View File

@@ -18,7 +18,7 @@ final class UploadHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Upload Help — Skinbase',
'Learn how uploading works on Skinbase Nova, including draft creation, metadata review, previews, personal versus Group context, contributor credit, publishing, and troubleshooting.',
'Learn how uploading works on Skinbase, including draft creation, metadata review, previews, personal versus Group context, contributor credit, publishing, and troubleshooting.',
$canonical,
)
->toArray();
@@ -27,7 +27,7 @@ final class UploadHelpPageController extends Controller
return Inertia::render('Help/UploadHelpPage', [
'title' => 'Upload Help',
'description' => 'Understand the full upload workflow on Skinbase Nova, from file submission and draft creation to metadata review, contributor credit, and final publish.',
'description' => 'Understand the full upload workflow on Skinbase, from file submission and draft creation to metadata review, contributor credit, and final publish.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),

View File

@@ -22,7 +22,7 @@ final class WorldController extends Controller
{
$payload = $this->worlds->publicIndexPayload($request->user());
$seo = app(SeoFactory::class)->collectionListing(
'Worlds — Skinbase Nova',
'Worlds — Skinbase',
$payload['description'],
route('worlds.index'),
)->toArray();
@@ -45,8 +45,8 @@ final class WorldController extends Controller
$payload = $this->worlds->publicShowPayload($resolvedWorld, $request->user());
$seo = app(SeoFactory::class)->collectionPage(
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase Nova'),
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase'),
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase.'),
$this->worlds->canonicalPublicUrl($resolvedWorld),
$resolvedWorld->ogImageUrl(),
)->toArray();
@@ -69,8 +69,8 @@ final class WorldController extends Controller
$payload = $this->worlds->publicShowPayload($resolvedWorld, $request->user());
$seo = app(SeoFactory::class)->collectionPage(
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase Nova'),
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase Nova.'),
$resolvedWorld->seo_title ?: ($resolvedWorld->title . ' — Skinbase'),
$resolvedWorld->seo_description ?: ($resolvedWorld->summary ?: $resolvedWorld->description ?: 'Seasonal and editorial discovery world on Skinbase.'),
$this->worlds->canonicalPublicUrl($resolvedWorld),
$resolvedWorld->ogImageUrl(),
)->toArray();

View File

@@ -18,7 +18,7 @@ final class WorldsHelpPageController extends Controller
$seo = app(SeoFactory::class)
->collectionPage(
'Worlds Help — Skinbase',
'Learn how Worlds work on Skinbase Nova, including editorial purpose, attached content, section control, preview, publishing, recurrence, and homepage promotion.',
'Learn how Worlds work on Skinbase, including editorial purpose, attached content, section control, preview, publishing, recurrence, and homepage promotion.',
$canonical,
)
->toArray();
@@ -27,7 +27,7 @@ final class WorldsHelpPageController extends Controller
return Inertia::render('Help/WorldsHelpPage', [
'title' => 'Worlds Help',
'description' => 'A complete guide to creating, attaching content to, previewing, and publishing Worlds on Skinbase Nova.',
'description' => 'A complete guide to creating, attaching content to, previewing, and publishing Worlds on Skinbase.',
'seo' => $seo,
'links' => [
'help_home' => route('help'),

View File

@@ -6,6 +6,7 @@ namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\ViewErrorBag;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class ConditionalShareErrorsFromSession extends ShareErrorsFromSession
@@ -17,6 +18,8 @@ class ConditionalShareErrorsFromSession extends ShareErrorsFromSession
}
if ($request->attributes->get('skinbase.session_skipped') === true || ! $request->hasSession()) {
$this->view->share('errors', new ViewErrorBag());
return $next($request);
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class EnsureAdminRole
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user || ! $user->isAdmin()) {
abort(Response::HTTP_FORBIDDEN, 'Only admins can access this area.');
}
return $next($request);
}
}

View File

@@ -19,7 +19,7 @@ final class EnsureStaffAccess
abort(Response::HTTP_FORBIDDEN, 'Forbidden.');
}
return redirect()->route('home')->with('error', 'You do not have access to this area.');
return redirect()->route('index')->with('error', 'You do not have access to this area.');
}
return $next($request);

View File

@@ -22,30 +22,48 @@ class RedirectLegacyProfileSubdomain
return redirect()->to($this->targetUrl($request, $canonicalUsername), 301);
}
if ($this->shouldRedirectToCanonicalHost($request)) {
return redirect()->to($this->canonicalHostUrl($request), 301);
}
return $next($request);
}
private function resolveCanonicalUsername(Request $request): ?string
private function shouldRedirectToCanonicalHost(Request $request): bool
{
return $this->isSingleSubdomainOnConfiguredHost($request);
}
private function isSingleSubdomainOnConfiguredHost(Request $request): bool
{
$configuredHost = parse_url((string) config('app.url'), PHP_URL_HOST);
if (! is_string($configuredHost) || $configuredHost === '') {
return null;
return false;
}
$requestHost = strtolower($request->getHost());
$configuredHost = strtolower($configuredHost);
if ($requestHost === $configuredHost || ! str_ends_with($requestHost, '.' . $configuredHost)) {
return null;
return false;
}
$subdomain = substr($requestHost, 0, -strlen('.' . $configuredHost));
if ($subdomain === '' || str_contains($subdomain, '.')) {
return $subdomain !== '' && ! str_contains($subdomain, '.');
}
private function resolveCanonicalUsername(Request $request): ?string
{
if (! $this->isSingleSubdomainOnConfiguredHost($request)) {
return null;
}
$configuredHost = strtolower((string) parse_url((string) config('app.url'), PHP_URL_HOST));
$requestHost = strtolower($request->getHost());
$subdomain = substr($requestHost, 0, -strlen('.' . $configuredHost));
$candidate = UsernamePolicy::normalize($subdomain);
if ($candidate === '' || $this->isReservedSubdomain($candidate)) {
@@ -103,4 +121,16 @@ class RedirectLegacyProfileSubdomain
return $target;
}
private function canonicalHostUrl(Request $request): string
{
$target = rtrim((string) config('app.url'), '/') . $request->getPathInfo();
$query = $request->getQueryString();
if (is_string($query) && $query !== '') {
$target .= '?' . $query;
}
return $target;
}
}

View File

@@ -3,7 +3,9 @@
namespace App\Http\Requests\Auth;
use App\Models\User;
use App\Services\Auth\AuthAuditLogger;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
@@ -68,6 +70,16 @@ class LoginRequest extends FormRequest
if (! $user || ! Hash::check($password, (string) $user->password)) {
RateLimiter::hit($this->throttleKey());
app(AuthAuditLogger::class)->log(
eventType: 'login',
request: $this,
status: 'failed',
reason: 'invalid_credentials',
identifier: $identifier,
user: $user,
metadata: ['via' => $authenticatedVia]
);
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
@@ -90,6 +102,20 @@ class LoginRequest extends FormRequest
return $this->authenticatedVia === 'username';
}
protected function failedValidation(Validator $validator): void
{
app(AuthAuditLogger::class)->log(
eventType: 'login',
request: $this,
status: 'failed',
reason: 'validation_failed',
identifier: (string) $this->input('email'),
metadata: ['fields' => array_keys($validator->errors()->toArray())]
);
parent::failedValidation($validator);
}
/**
* Ensure the login request is not rate limited.
*
@@ -105,6 +131,15 @@ class LoginRequest extends FormRequest
$seconds = RateLimiter::availableIn($this->throttleKey());
app(AuthAuditLogger::class)->log(
eventType: 'login',
request: $this,
status: 'failed',
reason: 'rate_limited',
identifier: (string) $this->input('email'),
metadata: ['seconds' => $seconds]
);
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,

View File

@@ -3,6 +3,7 @@ namespace App\Http\Resources;
use App\Models\WorldRelation;
use App\Models\WorldSubmission;
use App\Models\World;
use App\Services\ArtworkEvolutionService;
use App\Services\ContentSanitizer;
use App\Services\Maturity\ArtworkMaturityService;
@@ -336,58 +337,70 @@ class ArtworkResource extends JsonResource
private function resolveWorldParticipation(): array
{
$items = collect();
$participationWorlds = collect();
if (Schema::hasTable('world_relations') && Schema::hasTable('worlds')) {
$items = $items->concat(
WorldRelation::query()
->with('world')
->where('related_type', WorldRelation::TYPE_ARTWORK)
->where('related_id', (int) $this->id)
->get()
->filter(fn (WorldRelation $relation): bool => $relation->world !== null && $relation->world->isPubliclyVisible())
->map(function (WorldRelation $relation): array {
$world = $relation->world;
$relations = WorldRelation::query()
->with('world')
->where('related_type', WorldRelation::TYPE_ARTWORK)
->where('related_id', (int) $this->id)
->get()
->filter(fn (WorldRelation $relation): bool => $relation->world !== null && $relation->world->isPubliclyVisible())
->values();
return [
'world_id' => (int) $relation->world_id,
'world_title' => (string) $world->title,
'world_slug' => (string) $world->slug,
'world_url' => $world->publicUrl(),
'badge_label' => 'Part of ' . $world->title,
'status' => 'curated',
'status_label' => 'Curated',
'tone' => 'curated',
'sort_priority' => 1,
];
})
$participationWorlds = $participationWorlds->concat($relations->pluck('world')->filter());
$items = $items->concat(
$relations->map(function (WorldRelation $relation): array {
$world = $relation->world;
return [
'world_id' => (int) $relation->world_id,
'world_title' => (string) $world->title,
'world_slug' => (string) $world->slug,
'world_url' => $world->publicUrl(),
'badge_label' => 'Part of ' . $world->title,
'status' => 'curated',
'status_label' => 'Curated',
'tone' => 'curated',
'sort_priority' => 1,
];
})
);
}
if (Schema::hasTable('world_submissions')) {
$items = $items->concat(
$this->worldSubmissions
->filter(function (WorldSubmission $submission): bool {
return (string) $submission->status === WorldSubmission::STATUS_LIVE
&& $submission->world !== null
&& $submission->world->isPubliclyVisible();
})
->map(function (WorldSubmission $submission): array {
$world = $submission->world;
$isFeatured = (bool) $submission->is_featured;
$liveSubmissions = $this->worldSubmissions
->filter(function (WorldSubmission $submission): bool {
return (string) $submission->status === WorldSubmission::STATUS_LIVE
&& $submission->world !== null
&& $submission->world->isPubliclyVisible();
})
->values();
return [
'world_id' => (int) $submission->world_id,
'world_title' => (string) $world->title,
'world_slug' => (string) $world->slug,
'world_url' => $world->publicUrl(),
'badge_label' => ($isFeatured ? 'Featured in ' : 'Part of ') . $world->title,
'status' => (string) $submission->status,
'status_label' => $isFeatured ? 'Featured' : 'Community submission',
'tone' => $isFeatured ? 'featured' : 'community',
'sort_priority' => $isFeatured ? 0 : 2,
];
})
$participationWorlds = $participationWorlds->concat($liveSubmissions->pluck('world')->filter());
World::primeCanonicalEditionIds($participationWorlds->pluck('recurrence_key')->all());
$items = $items->concat(
$liveSubmissions->map(function (WorldSubmission $submission): array {
$world = $submission->world;
$isFeatured = (bool) $submission->is_featured;
return [
'world_id' => (int) $submission->world_id,
'world_title' => (string) $world->title,
'world_slug' => (string) $world->slug,
'world_url' => $world->publicUrl(),
'badge_label' => ($isFeatured ? 'Featured in ' : 'Part of ') . $world->title,
'status' => (string) $submission->status,
'status_label' => $isFeatured ? 'Featured' : 'Community submission',
'tone' => $isFeatured ? 'featured' : 'community',
'sort_priority' => $isFeatured ? 0 : 2,
];
})
);
} elseif ($participationWorlds->isNotEmpty()) {
World::primeCanonicalEditionIds($participationWorlds->pluck('recurrence_key')->all());
}
if (Schema::hasTable('world_reward_grants')) {

View File

@@ -48,7 +48,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
public function handle(
ArtworkEmbeddingClient $client,
ArtworkVisionImageUrl $imageUrlBuilder,
VectorService|ArtworkVectorIndexService $vectors,
ArtworkVectorIndexService $vectors,
): void
{
if (! (bool) config('recommendations.embedding.enabled', true)) {
@@ -128,7 +128,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
}
private function upsertVectorIndex(
VectorService|ArtworkVectorIndexService $vectors,
ArtworkVectorIndexService $vectors,
Artwork $artwork
): void
{

View File

@@ -91,9 +91,9 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue
];
if (Schema::hasColumn('user_discovery_events', 'meta')) {
$insertPayload['meta'] = $this->meta;
$insertPayload['meta'] = $this->encodeMetaPayload();
} elseif (Schema::hasColumn('user_discovery_events', 'metadata')) {
$insertPayload['metadata'] = json_encode($this->meta, JSON_UNESCAPED_SLASHES);
$insertPayload['metadata'] = $this->encodeMetaPayload();
}
DB::table('user_discovery_events')->insertOrIgnore($insertPayload);
@@ -129,4 +129,12 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue
throw $e;
}
}
private function encodeMetaPayload(): string
{
return (string) json_encode(
$this->meta,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR
);
}
}

View File

@@ -28,20 +28,16 @@ class RegistrationVerificationMail extends Mailable implements ShouldQueue
public function envelope(): Envelope
{
return new Envelope(
subject: 'Verify your Skinbase email',
subject: 'Welcome to Skinbase — confirm your email',
);
}
public function content(): Content
{
$appUrl = rtrim((string) config('app.url', 'http://localhost'), '/');
return new Content(
view: 'emails.registration-verification',
with: [
'verificationUrl' => url('/verify/'.$this->token),
'expiresInHours' => max(1, (int) config('registration.verify_token_ttl_hours', 24)),
'supportUrl' => $appUrl . '/support',
],
);
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AuthAuditLog extends Model
{
protected $table = 'auth_audit_logs';
public $timestamps = false;
protected $fillable = [
'event_type',
'identifier',
'user_id',
'ip',
'user_agent',
'status',
'reason',
'metadata',
'created_at',
];
protected $casts = [
'metadata' => 'array',
'created_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -546,6 +546,32 @@ class World extends Model
return static::canonicalEditionIdForRecurrence((string) $this->recurrence_key) === (int) $this->id;
}
public static function primeCanonicalEditionIds(iterable $recurrenceKeys): void
{
$keys = collect($recurrenceKeys)
->map(static fn ($key): string => trim((string) $key))
->filter()
->unique()
->reject(static fn (string $key): bool => array_key_exists($key, static::$canonicalRecurrenceEditionIds))
->values();
if ($keys->isEmpty()) {
return;
}
$editionsByRecurrence = static::query()
->publiclyVisible()
->whereIn('recurrence_key', $keys->all())
->get()
->groupBy('recurrence_key');
foreach ($keys as $key) {
$canonical = static::selectCanonicalEdition(new EloquentCollection($editionsByRecurrence->get($key, collect())->all()));
static::$canonicalRecurrenceEditionIds[$key] = $canonical ? (int) $canonical->id : null;
}
}
public function sectionOrder(): array
{
$defaults = array_values(array_filter(config('worlds.default_section_order', []), 'is_string'));

View File

@@ -25,7 +25,7 @@ final class AiBiographyPromptBuilder
private const MIN_WORDS = 30;
private const SYSTEM_PROMPT = <<<'PROMPT'
You are a concise writing assistant for Skinbase Nova, a digital art platform.
You are a concise writing assistant for Skinbase, a digital art platform.
Write short creator biographies using only the facts provided. Use a polished, factual, and slightly editorial tone.
@@ -44,7 +44,7 @@ Rules:
PROMPT;
private const SYSTEM_PROMPT_STRICT = <<<'PROMPT'
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
You are a cautious writing assistant for Skinbase, a digital art platform.
Write a short, safe creator biography using only the facts provided. Be conservative.
@@ -59,7 +59,7 @@ Rules:
PROMPT;
private const SYSTEM_PROMPT_SPARSE = <<<'PROMPT'
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
You are a cautious writing assistant for Skinbase, a digital art platform.
Write a short, modest creator introduction using only the facts provided.
@@ -75,7 +75,7 @@ Rules:
PROMPT;
private const SYSTEM_PROMPT_SPARSE_STRICT = <<<'PROMPT'
You are a cautious writing assistant for Skinbase Nova, a digital art platform.
You are a cautious writing assistant for Skinbase, a digital art platform.
Write a short, modest creator introduction using only the facts provided. Be conservative and precise.

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Services\Auth;
use App\Models\AuthAuditLog;
use App\Models\User;
use Illuminate\Http\Request;
class AuthAuditLogger
{
public function log(
string $eventType,
?Request $request,
string $status,
?string $reason = null,
?string $identifier = null,
User|int|null $user = null,
array $metadata = [],
): AuthAuditLog {
$userId = $user instanceof User ? $user->getKey() : $user;
$cleanMetadata = array_filter($metadata, static fn (mixed $value): bool => $value !== null && $value !== '');
return AuthAuditLog::query()->create([
'event_type' => $eventType,
'identifier' => $this->normalizeIdentifier($identifier),
'user_id' => $userId,
'ip' => $request?->ip(),
'user_agent' => $request?->userAgent(),
'status' => $status,
'reason' => $reason,
'metadata' => $cleanMetadata === [] ? null : $cleanMetadata,
'created_at' => now(),
]);
}
private function normalizeIdentifier(?string $identifier): ?string
{
$identifier = trim((string) $identifier);
return $identifier === '' ? null : mb_strtolower($identifier);
}
}

View File

@@ -75,7 +75,7 @@ class CollectionAiCurationService
);
$seo = sprintf(
'%s on Skinbase Nova: %d curated artworks%s.',
'%s on Skinbase: %d curated artworks%s.',
$this->draftString($collection, $draft, 'title') ?: $collection->title,
$context['artworks_count'],
$context['theme_sentence'] !== '' ? ' exploring ' . $context['theme_sentence'] : ''

View File

@@ -142,7 +142,24 @@ class HomepageAnnouncementService
return $backgroundImage;
}
return Storage::disk('public')->url($backgroundImage);
$disk = $this->backgroundImageDisk();
$configuredBaseUrl = trim((string) config('filesystems.disks.' . $disk . '.url', ''), '/');
if ($configuredBaseUrl !== '') {
return $configuredBaseUrl . '/' . ltrim($backgroundImage, '/');
}
return Storage::disk($disk)->url($backgroundImage);
}
public function backgroundImageDisk(): string
{
return (string) config('homepage.announcements.background_image.disk', config('uploads.object_storage.disk', 's3'));
}
public function backgroundImagePrefix(): string
{
return trim((string) config('homepage.announcements.background_image.prefix', 'homepage-announcements'), '/');
}
private function artworkUrl(int $artworkId): ?string

View File

@@ -238,7 +238,7 @@ final class ArtworkSquareThumbnailBackfillService
'timeout' => 30,
'ignore_errors' => true,
'header' => implode("\r\n", [
'User-Agent: Skinbase Nova square-thumb backfill',
'User-Agent: Skinbase square-thumb backfill',
'Accept: image/*,*/*;q=0.8',
'Accept-Encoding: identity',
'Connection: close',

View File

@@ -9,7 +9,7 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
/**
* ArtworkRankingService Skinbase Nova Ranking Engine V2
* ArtworkRankingService Skinbase Ranking Engine V2
*
* Intelligent scoring system combining:
* 1. Base engagement (views, downloads, favourites, comments, shares)

View File

@@ -11,7 +11,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* RankingService Skinbase Nova rank_v2
* RankingService Skinbase rank_v2
*
* Responsibilities:
* 1. Score computation turn raw artwork signals into three float scores.

View File

@@ -33,7 +33,7 @@ final class GoogleNewsSitemapBuilder extends AbstractSitemapBuilder
route('news.show', ['slug' => $article->slug]),
trim((string) $article->title),
$article->published_at,
(string) \config('sitemaps.news.google_publication_name', 'Skinbase Nova'),
(string) \config('sitemaps.news.google_publication_name', 'Skinbase'),
(string) \config('sitemaps.news.google_language', 'en'),
);
})

View File

@@ -92,7 +92,7 @@ final class SitemapCacheService
{
$prefix = trim((string) config('sitemaps.pre_generated.path', 'generated-sitemaps'), '/');
$segments = $name === self::INDEX_DOCUMENT
? [$prefix, 'sitemap.xml']
? [$prefix, 'sitemaps', 'sitemap.xml']
: [$prefix, 'sitemaps', $name . '.xml'];
return implode('/', array_values(array_filter($segments, static fn (string $segment): bool => $segment !== '')));

View File

@@ -127,7 +127,7 @@ final class SitemapReleaseManager
public function documentRelativePath(string $documentName): string
{
return $documentName === SitemapCacheService::INDEX_DOCUMENT
? 'sitemap.xml'
? 'sitemaps/sitemap.xml'
: 'sitemaps/' . $documentName . '.xml';
}

View File

@@ -20,7 +20,7 @@ final class StudioAiCategoryMapper
$tokens = $this->tokenize($signals);
$haystack = ' ' . implode(' ', $tokens) . ' ';
$contentTypes = ContentType::query()->with(['rootCategories.children'])->ordered()->get();
$contentTypes = ContentType::query()->with(['rootCategories.children.parent'])->ordered()->get();
$contentTypeScores = $contentTypes
->map(fn (ContentType $contentType): array => $this->scoreContentType($contentType, $tokens, $haystack))
->filter(fn (array $row): bool => $row['score'] > 0)

View File

@@ -343,12 +343,22 @@ final class WorldRewardService
public function artworkRewardBadges(Artwork $artwork): array
{
return WorldRewardGrant::query()
$grants = WorldRewardGrant::query()
->with('world')
->where('artwork_id', (int) $artwork->id)
->orderByRaw($this->sortCaseSql())
->orderByDesc('granted_at')
->get()
->values();
World::primeCanonicalEditionIds(
$grants->pluck('world')
->filter()
->pluck('recurrence_key')
->all()
);
return $grants
->map(function (WorldRewardGrant $grant): array {
$world = $grant->world;
$rewardType = $grant->reward_type;

View File

@@ -1019,7 +1019,7 @@ final class WorldService
'title' => (string) $world->title,
'campaign_label' => (string) ($world->campaign_label ?: 'Live now'),
'status_label' => $this->campaignStateLabel($world),
'url' => $world->publicUrl(),
'url' => $this->publicPathForWorld($world),
];
});
}
@@ -2532,6 +2532,25 @@ final class WorldService
return route('worlds.show', ['world' => $recurrenceKey]);
}
private function publicPathForWorld(World $world): string
{
$recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
if (! $world->is_recurring || $recurrenceKey === '') {
return route('worlds.show', ['world' => $world->slug], false);
}
if ($this->isCanonicalSurfaceWorld($world)) {
return route('worlds.show', ['world' => $recurrenceKey], false);
}
if ($world->edition_year !== null) {
return route('worlds.editions.show', ['world' => $recurrenceKey, 'year' => $world->edition_year], false);
}
return route('worlds.show', ['world' => $recurrenceKey], false);
}
private function familyUrlForWorld(World $world): ?string
{
$recurrenceKey = trim((string) ($world->recurrence_key ?? ''));

View File

@@ -3,6 +3,7 @@
use App\Http\Middleware\ConditionalShareErrorsFromSession;
use App\Http\Middleware\ConditionalStartSession;
use App\Http\Middleware\ConditionalValidateCsrfToken;
use App\Http\Middleware\EnsureAdminRole;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
@@ -47,6 +48,7 @@ return Application::configure(basePath: dirname(__DIR__))
'artwork.maturity.access' => \App\Http\Middleware\EnsureArtworkMaturityAccess::class,
'admin.moderation' => \App\Http\Middleware\EnsureAdminOrModerator::class,
'admin.access' => \App\Http\Middleware\EnsureStaffAccess::class,
'admin.role' => EnsureAdminRole::class,
'creator.access' => \App\Http\Middleware\EnsureCreatorAccess::class,
'ensure.email.login.upgrade'=> \App\Http\Middleware\EnsureEmailLoginUpgradeComplete::class,
'ensure.onboarding.complete'=> \App\Http\Middleware\EnsureOnboardingComplete::class,

View File

@@ -1997,6 +1997,7 @@
"resources/js/Layouts/StudioLayout.jsx": [],
"resources/js/Pages/Admin/AiBiography.jsx": [],
"resources/js/Pages/Admin/Artworks.jsx": [],
"resources/js/Pages/Admin/AuthAudit.jsx": [],
"resources/js/Pages/Admin/Dashboard.jsx": [],
"resources/js/Pages/Admin/FeaturedArtworks.jsx": [],
"resources/js/Pages/Admin/HomepageAnnouncements/Form.jsx": [],

File diff suppressed because one or more lines are too long

View File

@@ -4,4 +4,10 @@ return [
'cache_store' => env('HOMEPAGE_CACHE_STORE', 'homepage'),
'guest_payload_key' => env('HOMEPAGE_GUEST_PAYLOAD_KEY', 'homepage.payload.guest'),
'guest_payload_ttl_seconds' => (int) env('HOMEPAGE_GUEST_PAYLOAD_TTL_SECONDS', 1800),
'announcements' => [
'background_image' => [
'disk' => env('HOMEPAGE_ANNOUNCEMENTS_BACKGROUND_DISK', env('ARTWORKS_OBJECT_DISK', 's3')),
'prefix' => trim((string) env('HOMEPAGE_ANNOUNCEMENTS_BACKGROUND_PREFIX', 'homepage-announcements'), '/'),
],
],
];

View File

@@ -3,7 +3,7 @@
declare(strict_types=1);
/**
* Ranking system configuration Skinbase Nova rank_v1
* Ranking system configuration Skinbase rank_v1
*
* All weights, half-lives, and thresholds are tunable here.
* Increment model_version when changing weights so caches expire gracefully.

View File

@@ -107,6 +107,8 @@ return [
'ws.skinbase.org',
'skinbase.top',
'www.skinbase.top',
'skinbase.si',
'www.skinbase.si',
'skinbase26.test'
],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),

View File

@@ -75,7 +75,7 @@ return [
'news' => [
'google_variant_enabled' => (bool) env('SITEMAPS_NEWS_GOOGLE_VARIANT', true),
'google_variant_name' => 'news-google',
'google_publication_name' => env('SITEMAPS_NEWS_GOOGLE_PUBLICATION', env('APP_NAME', 'Skinbase Nova')),
'google_publication_name' => env('SITEMAPS_NEWS_GOOGLE_PUBLICATION', env('APP_NAME', 'Skinbase')),
'google_language' => env('SITEMAPS_NEWS_GOOGLE_LANGUAGE', env('APP_LOCALE', 'en')),
'google_lookback_hours' => (int) env('SITEMAPS_NEWS_GOOGLE_LOOKBACK_HOURS', 48),
'google_max_items' => (int) env('SITEMAPS_NEWS_GOOGLE_MAX_ITEMS', 1000),

View File

@@ -126,6 +126,11 @@ return new class extends Migration
*/
private function indexExists(string $table, string $indexName): bool
{
if (DB::getDriverName() === 'sqlite') {
return collect(DB::select("PRAGMA index_list('{$table}')"))
->contains(static fn (object $row): bool => ($row->name ?? null) === $indexName);
}
return count(DB::select(
"SHOW INDEX FROM `{$table}` WHERE Key_name = ?",
[$indexName]

View File

@@ -15,6 +15,10 @@ return new class extends Migration
*/
public function up(): void
{
if (Schema::getConnection()->getDriverName() === 'sqlite') {
return;
}
Schema::table('artworks', function (Blueprint $table) {
$table->fullText(['title', 'description'], 'artworks_title_description_fulltext');
});
@@ -22,6 +26,10 @@ return new class extends Migration
public function down(): void
{
if (Schema::getConnection()->getDriverName() === 'sqlite') {
return;
}
Schema::table('artworks', function (Blueprint $table) {
$table->dropFullText('artworks_title_description_fulltext');
});

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
if (Schema::hasTable('auth_audit_logs')) {
return;
}
Schema::create('auth_audit_logs', function (Blueprint $table): void {
$table->id();
$table->string('event_type', 64);
$table->string('identifier')->nullable();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('ip', 45)->nullable();
$table->text('user_agent')->nullable();
$table->string('status', 32);
$table->string('reason', 64)->nullable();
$table->json('metadata')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index('event_type');
$table->index('identifier');
$table->index('user_id');
$table->index('ip');
$table->index(['event_type', 'status']);
$table->index('created_at');
});
}
public function down(): void
{
Schema::dropIfExists('auth_audit_logs');
}
};

View File

@@ -19,16 +19,16 @@ final class HomepageAnnouncementLaunchSeeder extends Seeder
[
'placement' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED,
'type' => HomepageAnnouncement::TYPE_LAUNCH,
'title' => 'Skinbase Nova is live.',
'title' => 'Skinbase is live.',
],
[
'subtitle' => 'A new chapter for the Skinbase creative community.',
'badge_text' => 'Launch Day · 1 May 2026',
'content_html' => implode("\n", [
'<p><strong>Today, 1 May 2026, Skinbase begins a new chapter.</strong></p>',
'<p>Skinbase Nova is a modern reboot of our creative community for digital art, wallpapers, skins, photography, customization, and discovery.</p>',
'<p>Skinbase is a modern reboot of our creative community for digital art, wallpapers, skins, photography, customization, and discovery.</p>',
'<p>We are bringing the spirit of classic Skinbase into a faster, cleaner, and more modern experience — built for creators, fans, and the future.</p>',
'<p>Welcome to <strong>Skinbase Nova</strong>.</p>',
'<p>Welcome to <strong>Skinbase</strong>.</p>',
]),
'status' => HomepageAnnouncement::STATUS_PUBLISHED,
'is_active' => true,

View File

@@ -54,11 +54,11 @@ final class NewsLaunchSeeder extends Seeder
$articles = [
[
'slug' => 'welcome-to-skinbase-nova',
'title' => 'Welcome to Skinbase Nova',
'title' => 'Welcome to Skinbase',
'type' => NewsArticle::TYPE_PLATFORM_UPDATE,
'category' => $categories['platform'],
'excerpt' => 'A first look at the refreshed Skinbase experience and the editorial direction behind Nova.',
'content' => "# Welcome to Skinbase Nova\n\nSkinbase Nova brings publishing, discovery, Groups, and editorial storytelling into a single platform experience.\n\n## What is new\n\n- a dedicated newsroom\n- stronger creator identity surfaces\n- deeper internal linking across Groups, releases, and profiles\n- cleaner editorial publishing tools inside Studio\n\nNova is designed to feel active, curated, and connected to the people making the work.",
'content' => "# Welcome to Skinbase\n\nSkinbase brings publishing, discovery, Groups, and editorial storytelling into a single platform experience.\n\n## What is new\n\n- a dedicated newsroom\n- stronger creator identity surfaces\n- deeper internal linking across Groups, releases, and profiles\n- cleaner editorial publishing tools inside Studio\n\nNova is designed to feel active, curated, and connected to the people making the work.",
'tags' => [$tags['nova'], $tags['platform-update']],
'days_ago' => 10,
'featured' => true,

View File

@@ -32,7 +32,7 @@ class NovaCardDemoSeeder extends Seeder
['email' => (string) Arr::get($userConfig, 'email', 'nova-cards-demo@skinbase.test')],
[
'username' => (string) Arr::get($userConfig, 'username', 'nova.cards'),
'name' => (string) Arr::get($userConfig, 'name', 'Nova Cards'),
'name' => (string) Arr::get($userConfig, 'name', 'Cards'),
'password' => (string) Arr::get($userConfig, 'password', 'password'),
'role' => 'user',
]
@@ -43,9 +43,9 @@ class NovaCardDemoSeeder extends Seeder
'slug' => 'official-spark',
'title' => 'Official Spark',
'quote_text' => 'Small moments of focus turn into visible momentum.',
'quote_author' => 'Skinbase Nova',
'quote_author' => 'Skinbase',
'quote_source' => 'Launch Collection',
'description' => 'An official Nova Cards demo card for featured browse surfaces.',
'description' => 'An official Cards demo card for featured browse surfaces.',
'category_slug' => 'motivation',
'template_slug' => 'neon-nova',
'format' => NovaCard::FORMAT_SQUARE,
@@ -61,9 +61,9 @@ class NovaCardDemoSeeder extends Seeder
'slug' => 'soft-breath',
'title' => 'Soft Breath',
'quote_text' => 'Rest is not a pause from growth. It is part of it.',
'quote_author' => 'Skinbase Nova',
'quote_author' => 'Skinbase',
'quote_source' => 'Healing Notes',
'description' => 'A calm demo card showing the softer side of Nova Cards.',
'description' => 'A calm demo card showing the softer side of Cards.',
'category_slug' => 'healing',
'template_slug' => 'soft-pastel',
'format' => NovaCard::FORMAT_PORTRAIT,
@@ -79,7 +79,7 @@ class NovaCardDemoSeeder extends Seeder
'slug' => 'night-echo',
'title' => 'Night Echo',
'quote_text' => 'Not every quiet room is empty. Some are full of answers.',
'quote_author' => 'Skinbase Nova',
'quote_author' => 'Skinbase',
'quote_source' => 'Dark Mood Study',
'description' => 'A darker official demo card for mood-oriented discovery blocks.',
'category_slug' => 'dark-mood',
@@ -97,7 +97,7 @@ class NovaCardDemoSeeder extends Seeder
'slug' => 'editorial-glow',
'title' => 'Editorial Glow',
'quote_text' => 'Design with restraint, then let one accent do the speaking.',
'quote_author' => 'Skinbase Nova',
'quote_author' => 'Skinbase',
'quote_source' => 'Editorial Kit',
'description' => 'A crisp editorial-format demo card for official collections.',
'category_slug' => 'motivation',
@@ -115,7 +115,7 @@ class NovaCardDemoSeeder extends Seeder
'slug' => 'story-bloom',
'title' => 'Story Bloom',
'quote_text' => 'If the layout breathes, the words can reach further.',
'quote_author' => 'Skinbase Nova',
'quote_author' => 'Skinbase',
'quote_source' => 'Story Vertical Pack',
'description' => 'A vertical story-oriented demo card for public browsing and challenges.',
'category_slug' => 'healing',
@@ -133,7 +133,7 @@ class NovaCardDemoSeeder extends Seeder
'slug' => 'remix-launch-variant',
'title' => 'Remix Launch Variant',
'quote_text' => 'Take the spark and give it a new rhythm.',
'quote_author' => 'Skinbase Nova',
'quote_author' => 'Skinbase',
'quote_source' => 'Remix Lab',
'description' => 'A seeded remix showing lineage in demo content.',
'category_slug' => 'motivation',
@@ -262,7 +262,7 @@ class NovaCardDemoSeeder extends Seeder
['user_id' => $user->id, 'slug' => 'editorial-favorites'],
[
'name' => 'Editorial Favorites',
'description' => 'Officially curated Nova Cards spotlighting launch visuals, remixes, and story-first layouts.',
'description' => 'Officially curated Cards spotlighting launch visuals, remixes, and story-first layouts.',
'visibility' => NovaCardCollection::VISIBILITY_PUBLIC,
'official' => true,
'featured' => true,

View File

@@ -1,5 +1,5 @@
# ---------------------------------------------------------------------------
# Nginx upstream / gateway error pages for Skinbase Nova
# Nginx upstream / gateway error pages for Skinbase
# ---------------------------------------------------------------------------
# Purpose:
# Serve a Nova-styled static HTML page for nginx-level upstream failures such

View File

@@ -1,6 +1,6 @@
# AI Biography
AI Biography is the Skinbase Nova feature that generates short, grounded creator biographies from public profile data. It is designed to be conservative: it prefers a safe, concise summary over a flashy or speculative one.
AI Biography is the Skinbase feature that generates short, grounded creator biographies from public profile data. It is designed to be conservative: it prefers a safe, concise summary over a flashy or speculative one.
This document explains how the feature works, what commands are available, where output is stored, and where users can see it.

View File

@@ -195,7 +195,7 @@ To enable it on production:
2. Add `fastcgi_intercept_errors on;` to every FastCGI location that should use the static fallback.
3. Keep the static file available in the live public path so nginx can serve it without Laravel.
On the current `skinbase.top` vhost, the required FastCGI locations are:
On the current `skinbase.org` vhost, the required FastCGI locations are:
- `location ^~ /api/uploads/`
- `location = /index.php`

View File

@@ -1,6 +1,6 @@
# Realtime Messaging
Skinbase Nova messaging now uses Laravel Reverb, Laravel Broadcasting, Laravel Echo, Redis-backed queues, and Laravel Horizon for queue visibility.
Skinbase messaging now uses Laravel Reverb, Laravel Broadcasting, Laravel Echo, Redis-backed queues, and Laravel Horizon for queue visibility.
## v2 capabilities

BIN
new_passwords.7z Normal file

Binary file not shown.

BIN
projekti_2026_skinbase.7z Normal file

Binary file not shown.

51
public/sitemap.xml Normal file
View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>http://skinbase26.test/sitemaps/artworks-index.xml</loc>
<lastmod>2026-04-17T11:36:53+00:00</lastmod>
</sitemap>
<sitemap>
<loc>http://skinbase26.test/sitemaps/users.xml</loc>
<lastmod>2026-04-25T11:51:06+00:00</lastmod>
</sitemap>
<sitemap>
<loc>http://skinbase26.test/sitemaps/tags.xml</loc>
<lastmod>2026-04-17T11:38:50+00:00</lastmod>
</sitemap>
<sitemap>
<loc>http://skinbase26.test/sitemaps/categories.xml</loc>
<lastmod>2026-04-11T06:46:51+00:00</lastmod>
</sitemap>
<sitemap>
<loc>http://skinbase26.test/sitemaps/collections.xml</loc>
<lastmod>2026-04-09T18:59:35+00:00</lastmod>
</sitemap>
<sitemap>
<loc>http://skinbase26.test/sitemaps/cards.xml</loc>
<lastmod>2026-03-31T13:55:44+00:00</lastmod>
</sitemap>
<sitemap>
<loc>http://skinbase26.test/sitemaps/stories.xml</loc>
<lastmod>2026-04-09T12:27:18+00:00</lastmod>
</sitemap>
<sitemap>
<loc>http://skinbase26.test/sitemaps/news.xml</loc>
<lastmod>2026-04-10T19:54:16+00:00</lastmod>
</sitemap>
<sitemap>
<loc>http://skinbase26.test/sitemaps/news-google.xml</loc>
</sitemap>
<sitemap>
<loc>http://skinbase26.test/sitemaps/forum-index.xml</loc>
</sitemap>
<sitemap>
<loc>http://skinbase26.test/sitemaps/forum-categories.xml</loc>
<lastmod>2026-03-08T18:41:25+00:00</lastmod>
</sitemap>
<sitemap>
<loc>http://skinbase26.test/sitemaps/forum-threads.xml</loc>
<lastmod>2026-04-17T11:36:54+00:00</lastmod>
</sitemap>
<sitemap>
<loc>http://skinbase26.test/sitemaps/static-pages.xml</loc>
</sitemap>
</sitemapindex>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>http://skinbase26.test/sitemaps/artworks-0001.xml</loc>
</sitemap>
<sitemap>
<loc>http://skinbase26.test/sitemaps/artworks-0002.xml</loc>
</sitemap>
<sitemap>
<loc>http://skinbase26.test/sitemaps/artworks-0003.xml</loc>
</sitemap>
<sitemap>
<loc>http://skinbase26.test/sitemaps/artworks-0004.xml</loc>
</sitemap>
<sitemap>
<loc>http://skinbase26.test/sitemaps/artworks-0005.xml</loc>
</sitemap>
</sitemapindex>

18
public/sitemaps/cards.xml Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
<url>
<loc>http://skinbase26.test/cards/nova-report-card-536447237-18</loc>
<lastmod>2026-03-31T13:44:38+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/cards/nova-report-card-913253507-19</loc>
<lastmod>2026-03-31T12:42:55+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/cards/sem-mali-zajcek-25</loc>
<lastmod>2026-03-31T13:55:44+00:00</lastmod>
<image:image>
<image:loc>https://cdn.skinbase.org/cards/previews/1/2556205a-ce7f-45ef-867b-77f63450bde6-og.jpg</image:loc>
<image:title>Sem mali zajček,</image:title>
</image:image>
</url>
</urlset>

Some files were not shown because too many files have changed in this diff Show More