chore: commit current workspace changes

This commit is contained in:
2026-05-02 09:37:14 +02:00
parent 79235133f0
commit caf1464aa5
121 changed files with 485218 additions and 181663 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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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,
@@ -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,
@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -1,301 +0,0 @@
import { r as reactExports, a as reactDomExports, R as React } from "./vendor-tiptap-DSw66HfW.js";
import { S as ShareToast } from "../ssr.js";
import "util";
import "stream";
import "path";
import "http";
import "https";
import "url";
import "fs";
import "crypto";
import "http2";
import "assert";
import "tty";
import "os";
import "zlib";
import "events";
import "node:process";
import "node:path";
import "node:url";
import "./vendor-tooltip-CIQaDNlG.js";
import "./vendor-realtime-cgmg5qQY.js";
import "buffer";
import "child_process";
import "net";
import "tls";
import "./vendor-motion-yDK3iGlC.js";
import "process";
import "async_hooks";
const FeedShareArtworkModal = reactExports.lazy(() => import("../ssr.js").then((n) => n.a));
function facebookUrl(url) {
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`;
}
function twitterUrl(url, title) {
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`;
}
function pinterestUrl(url, imageUrl, title) {
return `https://pinterest.com/pin/create/button/?url=${encodeURIComponent(url)}&media=${encodeURIComponent(imageUrl)}&description=${encodeURIComponent(title)}`;
}
function emailUrl(url, title) {
return `mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(url)}`;
}
function CopyIcon() {
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" }));
}
function CheckIcon() {
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 20 20", fill: "currentColor", className: "h-5 w-5 text-emerald-400" }, /* @__PURE__ */ React.createElement("path", { fillRule: "evenodd", d: "M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z", clipRule: "evenodd" }));
}
function FacebookIcon() {
return /* @__PURE__ */ React.createElement("svg", { viewBox: "0 0 24 24", fill: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { d: "M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12Z" }));
}
function XTwitterIcon() {
return /* @__PURE__ */ React.createElement("svg", { viewBox: "0 0 24 24", fill: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { d: "M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231 5.45-6.231Zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77Z" }));
}
function PinterestIcon() {
return /* @__PURE__ */ React.createElement("svg", { viewBox: "0 0 24 24", fill: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { d: "M12 2C6.477 2 2 6.477 2 12c0 4.236 2.636 7.855 6.356 9.312-.088-.791-.167-2.005.035-2.868.181-.78 1.172-4.97 1.172-4.97s-.299-.598-.299-1.482c0-1.388.806-2.425 1.808-2.425.853 0 1.265.64 1.265 1.408 0 .858-.546 2.14-.828 3.33-.236.995.5 1.807 1.482 1.807 1.778 0 3.144-1.874 3.144-4.58 0-2.393-1.72-4.068-4.177-4.068-2.845 0-4.515 2.135-4.515 4.34 0 .859.331 1.781.745 2.282a.3.3 0 0 1 .069.288l-.278 1.133c-.044.183-.145.222-.335.134-1.249-.581-2.03-2.407-2.03-3.874 0-3.154 2.292-6.052 6.608-6.052 3.469 0 6.165 2.472 6.165 5.776 0 3.447-2.173 6.22-5.19 6.22-1.013 0-1.965-.527-2.291-1.148l-.623 2.378c-.226.869-.835 1.958-1.244 2.621.937.29 1.931.446 2.962.446 5.523 0 10-4.477 10-10S17.523 2 12 2Z" }));
}
function EmailIcon() {
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" }));
}
function EmbedIcon() {
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 1.5, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" }));
}
function CloseIcon() {
return /* @__PURE__ */ React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", strokeWidth: 2, stroke: "currentColor", className: "h-5 w-5" }, /* @__PURE__ */ React.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M6 18 18 6M6 6l12 12" }));
}
function openShareWindow(url) {
window.open(url, "_blank", "noopener,noreferrer,width=600,height=500");
}
function trackShare(artworkId, platform) {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content");
fetch(`/api/artworks/${artworkId}/share`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-TOKEN": csrfToken || "" },
credentials: "same-origin",
body: JSON.stringify({ platform })
}).catch(() => {
});
}
function ArtworkShareModal({ open, onClose, artwork, shareUrl, isLoggedIn = false }) {
const backdropRef = reactExports.useRef(null);
const [linkCopied, setLinkCopied] = reactExports.useState(false);
const [embedCopied, setEmbedCopied] = reactExports.useState(false);
const [showEmbed, setShowEmbed] = reactExports.useState(false);
const [toastVisible, setToastVisible] = reactExports.useState(false);
const [toastMessage, setToastMessage] = reactExports.useState("");
const [profileShareOpen, setProfileShareOpen] = reactExports.useState(false);
const url = shareUrl || artwork?.canonical_url || (typeof window !== "undefined" ? window.location.href : "#");
const title = artwork?.title || "Artwork";
const imageUrl = artwork?.thumbs?.xl?.url || artwork?.thumbs?.lg?.url || artwork?.thumbs?.md?.url || "";
const thumbMdUrl = artwork?.thumbs?.md?.url || imageUrl;
const embedCode = `<a href="${url}">
<img src="${thumbMdUrl}" alt="${title.replace(/"/g, "&quot;")}" />
</a>`;
reactExports.useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}
}, [open]);
reactExports.useEffect(() => {
if (!open) return;
const handler = (e) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
reactExports.useEffect(() => {
if (open) {
setLinkCopied(false);
setEmbedCopied(false);
setShowEmbed(false);
}
}, [open]);
const showToast = reactExports.useCallback((msg) => {
setToastMessage(msg);
setToastVisible(true);
}, []);
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(url);
setLinkCopied(true);
showToast("Link copied!");
trackShare(artwork?.id, "copy");
setTimeout(() => setLinkCopied(false), 2500);
} catch {
}
};
const handleCopyEmbed = async () => {
try {
await navigator.clipboard.writeText(embedCode);
setEmbedCopied(true);
showToast("Embed code copied!");
trackShare(artwork?.id, "embed");
setTimeout(() => setEmbedCopied(false), 2500);
} catch {
}
};
const handlePlatformShare = (platform, shareLink) => {
openShareWindow(shareLink);
trackShare(artwork?.id, platform);
onClose();
};
if (!open) return null;
const SHARE_OPTIONS = [
{
label: linkCopied ? "Copied!" : "Copy Link",
icon: linkCopied ? /* @__PURE__ */ React.createElement(CheckIcon, null) : /* @__PURE__ */ React.createElement(CopyIcon, null),
onClick: handleCopyLink,
className: linkCopied ? "border-emerald-500/40 bg-emerald-500/15 text-emerald-400" : "border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
},
{
label: "Facebook",
icon: /* @__PURE__ */ React.createElement(FacebookIcon, null),
onClick: () => handlePlatformShare("facebook", facebookUrl(url)),
className: "border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-[#1877F2]/40 hover:bg-[#1877F2]/15 hover:text-[#1877F2]"
},
{
label: "X (Twitter)",
icon: /* @__PURE__ */ React.createElement(XTwitterIcon, null),
onClick: () => handlePlatformShare("twitter", twitterUrl(url, title)),
className: "border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/30 hover:bg-white/[0.10] hover:text-white"
},
{
label: "Pinterest",
icon: /* @__PURE__ */ React.createElement(PinterestIcon, null),
onClick: () => handlePlatformShare("pinterest", pinterestUrl(url, imageUrl, title)),
className: "border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-[#E60023]/40 hover:bg-[#E60023]/15 hover:text-[#E60023]"
},
{
label: "Email",
icon: /* @__PURE__ */ React.createElement(EmailIcon, null),
onClick: () => {
window.location.href = emailUrl(url, title);
trackShare(artwork?.id, "email");
},
className: "border-white/[0.08] bg-white/[0.04] text-white/70 hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
},
...isLoggedIn ? [{
label: "My Profile",
icon: /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-share-nodes h-5 w-5 text-[1.1rem]" }),
onClick: () => setProfileShareOpen(true),
className: "border-sky-500/30 bg-sky-500/10 text-sky-400 hover:border-sky-400/50 hover:bg-sky-500/20"
}] : []
];
return reactDomExports.createPortal(
/* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(
"div",
{
ref: backdropRef,
onClick: (e) => {
if (e.target === backdropRef.current) onClose();
},
className: "fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4",
role: "dialog",
"aria-modal": "true",
"aria-label": "Share this artwork"
},
/* @__PURE__ */ React.createElement("div", { className: "w-full max-w-md rounded-2xl border border-nova-700/50 bg-nova-900/80 shadow-2xl backdrop-blur-xl" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center justify-between border-b border-white/[0.06] px-6 py-4" }, /* @__PURE__ */ React.createElement("h3", { className: "text-base font-semibold text-white" }, "Share this artwork"), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
onClick: onClose,
className: "rounded-lg p-1.5 text-white/40 transition hover:bg-white/[0.06] hover:text-white/70",
"aria-label": "Close share dialog"
},
/* @__PURE__ */ React.createElement(CloseIcon, null)
)), thumbMdUrl && /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3 border-b border-white/[0.06] px-6 py-3" }, /* @__PURE__ */ React.createElement(
"img",
{
src: thumbMdUrl,
alt: title,
className: "h-14 w-14 rounded-lg object-cover",
loading: "lazy"
}
), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("p", { className: "truncate text-sm font-medium text-white" }, title), artwork?.user?.username && /* @__PURE__ */ React.createElement("p", { className: "truncate text-xs text-white/50" }, "by ", artwork.user.username))), /* @__PURE__ */ React.createElement("div", { className: "grid grid-cols-3 gap-2.5 px-6 py-5 sm:grid-cols-5" }, SHARE_OPTIONS.map((opt) => /* @__PURE__ */ React.createElement(
"button",
{
key: opt.label,
type: "button",
onClick: opt.onClick,
className: [
"flex flex-col items-center gap-1.5 rounded-xl border px-2 py-3 text-xs font-medium transition-all duration-200",
opt.className
].join(" ")
},
opt.icon,
/* @__PURE__ */ React.createElement("span", { className: "truncate" }, opt.label)
))), /* @__PURE__ */ React.createElement("div", { className: "border-t border-white/[0.06] px-6 py-4" }, /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
onClick: () => setShowEmbed(!showEmbed),
className: "flex items-center gap-2 text-sm font-medium text-white/60 transition hover:text-white/80"
},
/* @__PURE__ */ React.createElement(EmbedIcon, null),
showEmbed ? "Hide Embed Code" : "Embed Code",
/* @__PURE__ */ React.createElement(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
viewBox: "0 0 16 16",
fill: "currentColor",
className: `h-3.5 w-3.5 transition-transform duration-200 ${showEmbed ? "rotate-180" : ""}`
},
/* @__PURE__ */ React.createElement("path", { fillRule: "evenodd", d: "M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z", clipRule: "evenodd" })
)
), showEmbed && /* @__PURE__ */ React.createElement("div", { className: "mt-3 space-y-2" }, /* @__PURE__ */ React.createElement(
"textarea",
{
readOnly: true,
value: embedCode,
rows: 3,
className: "w-full resize-none rounded-xl border border-white/[0.08] bg-white/[0.03] px-4 py-3 font-mono text-xs text-white/70 outline-none focus:border-white/[0.15]",
onClick: (e) => e.target.select()
}
), /* @__PURE__ */ React.createElement(
"button",
{
type: "button",
onClick: handleCopyEmbed,
className: [
"inline-flex items-center gap-1.5 rounded-full border px-4 py-1.5 text-xs font-medium transition-all duration-200",
embedCopied ? "border-emerald-500/40 bg-emerald-500/15 text-emerald-400" : "border-white/[0.08] bg-white/[0.04] text-white/60 hover:border-white/[0.15] hover:text-white/80"
].join(" ")
},
embedCopied ? /* @__PURE__ */ React.createElement(CheckIcon, null) : /* @__PURE__ */ React.createElement(CopyIcon, null),
embedCopied ? "Copied!" : "Copy Embed"
))))
), /* @__PURE__ */ React.createElement(
ShareToast,
{
message: toastMessage,
visible: toastVisible,
onHide: () => setToastVisible(false)
}
), profileShareOpen && /* @__PURE__ */ React.createElement(reactExports.Suspense, { fallback: null }, /* @__PURE__ */ React.createElement(
FeedShareArtworkModal,
{
isOpen: profileShareOpen,
onClose: () => setProfileShareOpen(false),
preselectedArtwork: artwork?.id ? {
id: artwork.id,
title: artwork.title,
thumb_url: artwork.thumbs?.md?.url ?? artwork.thumbs?.lg?.url ?? null,
user: artwork.user ?? null
} : null,
onShared: () => {
setProfileShareOpen(false);
showToast("Shared to your profile!");
}
}
))),
document.body
);
}
export {
ArtworkShareModal as default
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

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

@@ -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

@@ -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

@@ -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`

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>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>http://skinbase26.test/@gregor/collections/geometrical-shapes</loc>
<lastmod>2026-04-09T18:59:35+00:00</lastmod>
</url>
</urlset>

View File

@@ -0,0 +1,250 @@
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>http://skinbase26.test/forum/category/other</loc>
<lastmod>2026-02-17T16:33:22+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/category/administrators-and-moderators-forum</loc>
<lastmod>2026-02-17T16:33:22+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/category/photography</loc>
<lastmod>2026-02-17T16:33:22+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/category/community</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/category/digital-art</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/category/wallpapers</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/category/creators-workflow</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/category/desktop-customization</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/category/skinbase-platform</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/category/collaboration</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/category/off-topic</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/category/legacy</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/suggestions-2</loc>
<lastmod>2026-03-08T18:39:16+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/other</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/help-2</loc>
<lastmod>2026-03-08T18:41:25+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/freeware-appreciation-threads</loc>
<lastmod>2026-03-08T18:38:15+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/administrators-and-moderators-forum</loc>
<lastmod>2026-03-08T17:04:00+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/photography</loc>
<lastmod>2026-03-08T17:04:16+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/introductions</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/ai-art</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/showcase</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/art-software</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/rainmeter</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/news-discussion</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/artist-collaboration</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/gaming</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/classic-skinning</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/general-discussion</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/3d-art</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/requests</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/techniques</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/litestep</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/site-updates</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/commission-requests</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/technology</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/old-software-discussions</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/illustration</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/tutorials</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/gear</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/assets</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/skintech-archive</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/bug-reports</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/movies</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/archived-tutorials</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/pixel-art</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/tools</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/editing</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/workflow</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/freeware-appreciation</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/beta-testing</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/random</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/historical-discussions</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/experimental</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/desktop-setup-showcase</loc>
<lastmod>2026-03-08T17:03:01+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/news</loc>
<lastmod>2026-03-08T18:29:00+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/skintechorg-forum</loc>
<lastmod>2026-03-08T18:29:00+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/litestep-discussion</loc>
<lastmod>2026-03-08T18:29:00+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/rip-reports-and-bugs</loc>
<lastmod>2026-03-08T18:29:00+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/skinner-forum</loc>
<lastmod>2026-03-08T18:33:14+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/general-art</loc>
<lastmod>2026-03-08T18:29:00+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/suggestions</loc>
<lastmod>2026-03-08T18:29:00+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/forum/help</loc>
<lastmod>2026-03-08T18:29:00+00:00</lastmod>
</url>
</urlset>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>http://skinbase26.test/forum</loc>
</url>
</urlset>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
</urlset>

2002
public/sitemaps/news.xml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>http://skinbase26.test</loc>
</url>
<url>
<loc>http://skinbase26.test/faq</loc>
</url>
<url>
<loc>http://skinbase26.test/rules-and-guidelines</loc>
</url>
<url>
<loc>http://skinbase26.test/privacy-policy</loc>
</url>
<url>
<loc>http://skinbase26.test/terms-of-service</loc>
</url>
<url>
<loc>http://skinbase26.test/staff</loc>
</url>
</urlset>

View File

@@ -0,0 +1,22 @@
<?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/stories/skinbase-nova-is-here</loc>
<lastmod>2026-04-09T12:27:18+00:00</lastmod>
</url>
<url>
<loc>http://skinbase26.test/stories/untitled-story</loc>
<lastmod>2026-03-20T16:59:40+00:00</lastmod>
<image:image>
<image:loc>https://miro.medium.com/v2/resize:fit:1400/format:webp/1*9VkZ5qvofC2cAIxgTcQPuw.jpeg</image:loc>
<image:title>sqlBackup: A Modern, Modular Solution for MySQL Backups</image:title>
</image:image>
</url>
<url>
<loc>http://skinbase26.test/stories/untitled-story-2</loc>
<lastmod>2026-03-21T20:15:04+00:00</lastmod>
<image:image>
<image:loc>http://thumb.skinbase.org/photo/i5n_6.jpg</image:loc>
<image:title>Wallpapers for Computer Desktop: Make Your Screen Look Better Every Day</image:title>
</image:image>
</url>
</urlset>

59150
public/sitemaps/tags.xml Normal file

File diff suppressed because it is too large Load Diff

9870
public/sitemaps/users.xml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react'
import { Link, usePage } from '@inertiajs/react'
const adminNavGroups = [
const buildAdminNavGroups = (isAdmin) => [
{
label: 'Overview',
items: [
@@ -31,6 +31,7 @@ const adminNavGroups = [
{
label: 'System',
items: [
...(isAdmin ? [{ label: 'Auth Audit', href: '/moderation/auth-audit', icon: 'fa-solid fa-user-shield' }] : []),
{ label: 'Settings', href: '/moderation/settings', icon: 'fa-solid fa-gear' },
],
},
@@ -51,7 +52,9 @@ function NavLink({ item, active }) {
)
}
function Sidebar({ pathname }) {
function Sidebar({ pathname, isAdmin }) {
const adminNavGroups = buildAdminNavGroups(isAdmin)
const isActive = (item) => {
if (item.exact) return pathname === item.href
return pathname.startsWith(item.href.split('?')[0])
@@ -103,9 +106,10 @@ function Sidebar({ pathname }) {
}
export default function AdminLayout({ children, title, subtitle }) {
const { url } = usePage()
const { url, props } = usePage()
const [mobileOpen, setMobileOpen] = useState(false)
const pathname = url.split('?')[0]
const currentUserIsAdmin = Boolean(props.auth?.user?.is_admin)
return (
<div className="flex min-h-screen bg-[radial-gradient(ellipse_at_top,_rgba(239,68,68,0.08),_transparent_40%),linear-gradient(180deg,#060a12_0%,#020409_100%)]">
@@ -113,7 +117,7 @@ export default function AdminLayout({ children, title, subtitle }) {
{/* Desktop sidebar */}
<div className="hidden lg:flex lg:w-64 lg:flex-shrink-0">
<div className="fixed inset-y-0 left-0 w-64">
<Sidebar pathname={pathname} />
<Sidebar pathname={pathname} isAdmin={currentUserIsAdmin} />
</div>
</div>
@@ -137,13 +141,13 @@ export default function AdminLayout({ children, title, subtitle }) {
<div className="fixed inset-0 z-30 lg:hidden">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setMobileOpen(false)} />
<div className="absolute left-0 top-0 h-full w-72 pt-14">
<Sidebar pathname={pathname} />
<Sidebar pathname={pathname} isAdmin={currentUserIsAdmin} />
</div>
</div>
)}
{/* Main content */}
<div className="flex flex-1 flex-col lg:pl-64">
<div className="flex flex-1 flex-col lg:pl-8">
<main className="flex-1 px-6 py-8 pt-20 lg:pt-8">
{(title || subtitle) && (
<div className="mb-8">

View File

@@ -0,0 +1,232 @@
import React from 'react'
import { Head, router } from '@inertiajs/react'
import AdminLayout from '../../Layouts/AdminLayout'
const EVENT_LABELS = {
login: 'Login',
register: 'Register',
forgot_password: 'Forgot password',
reset_password: 'Reset password',
}
const STATUS_BADGES = {
success: 'border-emerald-400/20 bg-emerald-400/10 text-emerald-200',
failed: 'border-rose-400/20 bg-rose-400/10 text-rose-200',
}
function formatTimestamp(value) {
if (!value) {
return 'Unknown'
}
return new Intl.DateTimeFormat('en-GB', {
dateStyle: 'medium',
timeStyle: 'medium',
}).format(new Date(value))
}
function formatLabel(value) {
if (!value) {
return 'Not recorded'
}
return value
.replaceAll('_', ' ')
.replace(/\b\w/g, (match) => match.toUpperCase())
}
function SummaryCard({ label, value, tone = 'slate' }) {
const tones = {
slate: 'border-white/[0.07] bg-white/[0.02] text-white',
rose: 'border-rose-400/15 bg-rose-500/10 text-rose-100',
sky: 'border-sky-400/15 bg-sky-500/10 text-sky-100',
}
return (
<div className={`rounded-2xl border p-5 ${tones[tone] ?? tones.slate}`}>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</p>
<p className="mt-3 text-3xl font-semibold tracking-tight">{value}</p>
</div>
)
}
export default function AuthAudit({ logs, filters, eventOptions, statusOptions }) {
const items = logs?.data ?? []
const failedCount = items.filter((entry) => entry.status === 'failed').length
const uniqueIpCount = new Set(items.map((entry) => entry.ip).filter(Boolean)).size
const handleSearch = (event) => {
event.preventDefault()
const search = event.target.elements.search.value
router.get('/moderation/auth-audit', {
search,
event: filters.event,
status: filters.status,
}, {
preserveState: true,
preserveScroll: true,
})
}
const handleFilterChange = (key, value) => {
router.get('/moderation/auth-audit', {
search: filters.search,
event: key === 'event' ? value : filters.event,
status: key === 'status' ? value : filters.status,
}, {
preserveState: true,
preserveScroll: true,
})
}
return (
<AdminLayout title="Auth Audit" subtitle="Review login, registration, forgot-password, and reset-password activity with IPs, timestamps, status, and failure reasons.">
<Head title="Admin · Auth Audit" />
<div className="grid gap-4 lg:grid-cols-3">
<SummaryCard label="Visible records" value={items.length.toLocaleString()} tone="slate" />
<SummaryCard label="Failures on page" value={failedCount.toLocaleString()} tone="rose" />
<SummaryCard label="Unique IPs on page" value={uniqueIpCount.toLocaleString()} tone="sky" />
</div>
<div className="mt-6 rounded-[28px] border border-white/[0.07] bg-white/[0.02] p-5">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<form onSubmit={handleSearch} className="flex flex-1 flex-col gap-3 md:flex-row">
<label className="flex-1">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Search</span>
<input
name="search"
defaultValue={filters.search}
placeholder="Email, username, IP, or failure reason"
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white placeholder:text-slate-600 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
/>
</label>
<button type="submit" className="rounded-2xl bg-rose-500/80 px-5 py-3 text-sm font-semibold text-white transition hover:bg-rose-500">
Search
</button>
</form>
<div className="grid gap-3 md:grid-cols-2 xl:min-w-[26rem]">
<label>
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Event</span>
<select
value={filters.event}
onChange={(event) => handleFilterChange('event', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
>
{eventOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-slate-950 text-white">
{option.label}
</option>
))}
</select>
</label>
<label>
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Status</span>
<select
value={filters.status}
onChange={(event) => handleFilterChange('status', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
>
{statusOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-slate-950 text-white">
{option.label}
</option>
))}
</select>
</label>
</div>
</div>
</div>
<div className="mt-6 overflow-hidden rounded-[28px] border border-white/[0.07] bg-white/[0.02]">
<div className="overflow-x-auto">
<table className="w-full min-w-[980px] text-sm">
<thead>
<tr className="border-b border-white/[0.07] text-left text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
<th className="px-5 py-4">When</th>
<th className="px-5 py-4">Event</th>
<th className="px-5 py-4">Status</th>
<th className="px-5 py-4">Identifier</th>
<th className="px-5 py-4">User</th>
<th className="px-5 py-4">IP</th>
<th className="px-5 py-4">Reason</th>
<th className="px-5 py-4">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.05]">
{items.length === 0 ? (
<tr>
<td colSpan={8} className="px-5 py-12 text-center text-slate-500">No auth audit records matched the current filters.</td>
</tr>
) : items.map((entry) => (
<tr key={entry.id} className="align-top transition hover:bg-white/[0.025]">
<td className="px-5 py-4 text-slate-300">{formatTimestamp(entry.created_at)}</td>
<td className="px-5 py-4 text-white">{EVENT_LABELS[entry.event_type] ?? formatLabel(entry.event_type)}</td>
<td className="px-5 py-4">
<span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${STATUS_BADGES[entry.status] ?? 'border-white/10 bg-white/[0.04] text-white/70'}`}>
{entry.status}
</span>
</td>
<td className="px-5 py-4 text-slate-300">{entry.identifier || 'Not recorded'}</td>
<td className="px-5 py-4">
{entry.user ? (
<div>
<p className="font-medium text-white">{entry.user.name}</p>
<p className="text-xs text-slate-500">{entry.user.username ? `@${entry.user.username}` : entry.user.email}</p>
</div>
) : <span className="text-slate-500">Unknown user</span>}
</td>
<td className="px-5 py-4 text-slate-300">{entry.ip || 'Unknown'}</td>
<td className="px-5 py-4 text-slate-300">{formatLabel(entry.reason)}</td>
<td className="px-5 py-4">
<details className="group w-72 max-w-full">
<summary className="cursor-pointer list-none text-sm font-medium text-sky-200 transition hover:text-sky-100">
View payload
</summary>
<div className="mt-3 space-y-3 rounded-2xl border border-white/10 bg-black/20 p-4 text-xs text-slate-300">
<div>
<p className="font-semibold uppercase tracking-[0.16em] text-slate-500">User agent</p>
<p className="mt-1 break-words leading-5 text-slate-300">{entry.user_agent || 'Not recorded'}</p>
</div>
<div>
<p className="font-semibold uppercase tracking-[0.16em] text-slate-500">Metadata</p>
<pre className="mt-1 overflow-x-auto whitespace-pre-wrap break-words rounded-xl bg-slate-950/70 p-3 text-[11px] leading-5 text-slate-300">{JSON.stringify(entry.metadata || {}, null, 2)}</pre>
</div>
</div>
</details>
</td>
</tr>
))}
</tbody>
</table>
</div>
{logs?.last_page > 1 ? (
<div className="flex items-center justify-between border-t border-white/[0.06] px-5 py-4">
<p className="text-xs text-slate-500">
Showing {logs.from}{logs.to} of {logs.total} audit records
</p>
<div className="flex gap-1">
{logs.links.map((link, index) => (
link.url ? (
<button
key={`${link.label}-${index}`}
type="button"
onClick={() => router.get(link.url, {}, { preserveScroll: true, preserveState: true })}
className={`rounded-lg px-3 py-1.5 text-xs transition ${link.active ? 'bg-rose-500/20 font-semibold text-rose-300' : 'text-slate-500 hover:bg-white/[0.06] hover:text-white'}`}
dangerouslySetInnerHTML={{ __html: link.label }}
/>
) : (
<span key={`${link.label}-${index}`} className="rounded-lg px-3 py-1.5 text-xs text-slate-700" dangerouslySetInnerHTML={{ __html: link.label }} />
)
))}
</div>
</div>
) : null}
</div>
</AdminLayout>
)
}

View File

@@ -151,7 +151,7 @@ export default function AiBiographyAdmin() {
}
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="AI Biography Review" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.2),transparent_32%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.9))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">

View File

@@ -108,7 +108,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

@@ -7,6 +7,11 @@ const pages = {
'!./Pages/Help/**/__tests__/**',
'!./Pages/Help/**/*.test.jsx',
]),
...import.meta.glob([
'./Pages/Profile/**/*.jsx',
'!./Pages/Profile/**/__tests__/**',
'!./Pages/Profile/**/*.test.jsx',
]),
...import.meta.glob([
'./Pages/Collection/**/*.jsx',
'!./Pages/Collection/**/__tests__/**',

View File

@@ -57,6 +57,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

@@ -1,27 +0,0 @@
import { mountInertiaRoot } from './bootstrap'
import React from 'react'
import { createInertiaApp } from '@inertiajs/react'
import ProfileShow from './Pages/Profile/ProfileShow'
import ProfileGallery from './Pages/Profile/ProfileGallery'
import CollectionShow from './Pages/Collection/CollectionShow'
import CollectionSeriesShow from './Pages/Collection/CollectionSeriesShow'
import CollectionManage from './Pages/Collection/CollectionManage'
import CollectionFeaturedIndex from './Pages/Collection/CollectionFeaturedIndex'
import SavedCollections from './Pages/Collection/SavedCollections'
const pages = {
'Profile/ProfileShow': ProfileShow,
'Profile/ProfileGallery': ProfileGallery,
'Collection/CollectionShow': CollectionShow,
'Collection/CollectionSeriesShow': CollectionSeriesShow,
'Collection/CollectionManage': CollectionManage,
'Collection/CollectionFeaturedIndex': CollectionFeaturedIndex,
'Collection/SavedCollections': SavedCollections,
}
createInertiaApp({
resolve: (name) => pages[name],
setup({ el, App, props }) {
mountInertiaRoot(el, App, props)
},
})

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify your email</title>
<title>Welcome to Skinbase</title>
</head>
<body style="margin:0;padding:20px;background:#0b0f14;font-family:system-ui,-apple-system,Segoe UI,Roboto,'Helvetica Neue',Arial;color:#e6eef6;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
@@ -17,19 +17,30 @@
</tr>
<tr>
<td style="padding:24px;background:linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0));">
<p style="margin:0 0 12px;color:#cbd5e1;">Welcome to {{ config('app.name', 'Skinbase') }} thanks for signing up.</p>
<p style="margin:0 0 18px;color:#cbd5e1;">Please verify your email to continue account setup.</p>
<p style="margin:0 0 16px;color:#e5edf5;">Hello,</p>
<p style="margin:0 0 16px;color:#cbd5e1;">Welcome to Skinbase.</p>
<p style="margin:0 0 20px;color:#cbd5e1;">Please confirm your email address to activate your Skinbase account. This helps us protect your account and keep the Skinbase community safe.</p>
<div style="text-align:center;margin:20px 0;">
<a href="{{ $verificationUrl }}" style="display:inline-block;padding:12px 20px;background:#0ea5a9;color:#06121a;text-decoration:none;border-radius:8px;font-weight:600;">Verify Email</a>
<a href="{{ $verificationUrl }}" style="display:inline-block;padding:12px 20px;background:#0ea5a9;color:#06121a;text-decoration:none;border-radius:8px;font-weight:700;">Confirm Email Address</a>
</div>
<p style="margin:0 0 8px;color:#9fb0c8;font-size:13px;">This link expires in {{ $expiresInHours }} hours.</p>
<p style="margin:12px 0 0;color:#9fb0c8;font-size:13px;">Need help? Contact support: <a href="{{ $supportUrl }}" style="color:#8bd0d3;">{{ $supportUrl }}</a></p>
<p style="margin:0 0 16px;color:#cbd5e1;">If you did not create a Skinbase account, you can safely ignore this email. No account will be activated unless this email address is confirmed.</p>
<p style="margin:0 0 20px;color:#cbd5e1;">Regards,<br>The Skinbase Team</p>
<p style="margin:0 0 8px;color:#9fb0c8;font-size:13px;">If the button does not work, copy and paste this link into your browser:</p>
<p style="margin:0 0 24px;color:#8bd0d3;font-size:13px;word-break:break-all;">
<a href="{{ $verificationUrl }}" style="color:#8bd0d3;">{{ $verificationUrl }}</a>
</p>
</td>
</tr>
<tr>
<td style="padding:12px 24px;background:#040607;border-top:1px solid #0e1113;text-align:center;color:#6b7280;font-size:12px;">© {{ date('Y') }} {{ config('app.name', 'Skinbase') }}. All rights reserved.</td>
<td style="padding:16px 24px;background:#040607;border-top:1px solid #0e1113;text-align:center;color:#9ca3af;font-size:12px;line-height:1.6;">
<div style="margin:0 0 6px;color:#e5edf5;font-weight:600;">Skinbase</div>
<div style="margin:0 0 12px;">Digital art, wallpapers, skins, photography, and creative customization.</div>
<div style="margin:0 0 6px;">You received this email because someone created a Skinbase account using this email address.</div>
<div style="margin:0;">© 2026 Skinbase. All rights reserved.</div>
</td>
</tr>
</table>
</td>

View File

@@ -3,7 +3,7 @@
@php
use App\Banner;
$src = $sourceArtwork;
$useUnifiedSeo = true;
$useUnifiedSeo = false;
@endphp
@push('head')

View File

@@ -7,10 +7,12 @@
$deferWebManifest = request()->routeIs('index');
$isInertiaPage = isset($page) && is_array($page);
$shouldRenderBladeSeo = ($useUnifiedSeo ?? false) && (($renderBladeSeo ?? false) || ! $isInertiaPage);
$novaViteEntries = [
$novaCssEntries = [
'resources/css/app.css',
'resources/css/nova-grid.css',
'resources/scss/nova.scss',
];
$novaViteEntries = [
'resources/js/nova.js',
];
@@ -54,6 +56,9 @@
@if(!$deferWebManifest)
<link rel="manifest" href="/favicon/site.webmanifest" />
@endif
@foreach($novaCssEntries as $novaCssEntry)
<link rel="stylesheet" href="{{ Vite::asset($novaCssEntry) }}">
@endforeach
@vite($novaViteEntries)
<script>
window.SKINBASE_LIMITS = Object.assign({}, window.SKINBASE_LIMITS || {}, {
@@ -255,6 +260,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

@@ -326,6 +326,7 @@
$routeUpload = Route::has('upload') ? route('upload') : '/upload';
$routeDashboard = Route::has('dashboard') ? route('dashboard') : '/dashboard';
$routeMyArtworks = Route::has('studio.artworks') ? route('studio.artworks') : '/studio/artworks';
$routeModeration = '/moderation';
$routeMyStories = Route::has('creator.stories.index') ? route('creator.stories.index') : '/creator/stories';
$routeWriteStory = Route::has('creator.stories.create') ? route('creator.stories.create') : '/creator/stories/create';
$routeDashboardFavorites = Route::has('dashboard.favorites') ? route('dashboard.favorites') : '/dashboard/favorites';
@@ -394,6 +395,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="{{ $routeMyStories }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-book-open text-xs text-sb-muted"></i></span>
My Stories
@@ -423,13 +430,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

@@ -2,6 +2,7 @@
$commentsCollection = $comments ?? collect();
$commentsCount = $commentsCount ?? $commentsCollection->count();
$viewer = auth()->user();
$errors = $errors ?? new \Illuminate\Support\ViewErrorBag();
@endphp
@if($isPreview)

View File

@@ -6,7 +6,7 @@
@extends('layouts.nova')
@push('head')
@vite(['resources/js/profile.jsx'])
@vite(['resources/js/collections.jsx'])
<meta name="csrf-token" content="{{ csrf_token() }}" />
<style>
/* Ensure profile tab bar does not hide behind the main navbar */

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

@@ -15,6 +15,12 @@
$seo = \App\Support\Seo\SeoDataBuilder::fromArray(
app(\App\Support\Seo\SeoFactory::class)->fromViewData(get_defined_vars())
)->build();
$communityActivityManifestPath = public_path('build/manifest.json');
$communityActivityManifest = is_file($communityActivityManifestPath)
? json_decode((string) file_get_contents($communityActivityManifestPath), true)
: null;
$communityActivityViteReady = is_array($communityActivityManifest)
&& array_key_exists('resources/js/Pages/Community/CommunityActivityPage.jsx', $communityActivityManifest);
$initialFilterLabel = match (($initialFilter ?? 'all')) {
'comments' => 'Comments',
@@ -243,6 +249,8 @@
</div>
</div>
@vite(['resources/js/Pages/Community/CommunityActivityPage.jsx'])
@if ($communityActivityViteReady)
@vite(['resources/js/Pages/Community/CommunityActivityPage.jsx'])
@endif
@endsection

View File

@@ -202,19 +202,16 @@ Schedule::command('collections:dispatch-maintenance')
->withoutOverlapping()
->runInBackground();
Schedule::job(new \App\Jobs\RankComputeArtworkScoresJob())
->hourlyAt(5)
->name('rank-compute-artwork-scores');
Schedule::job(new \App\Jobs\RankBuildListsJob())
->hourlyAt(15)
->name('rank-build-lists')
->withoutOverlapping();
Schedule::job(new \App\Jobs\UpdateLeaderboardsJob())
->hourlyAt(20)
Schedule::command('leaderboards:refresh')
->hourlyAt(21)
->name('leaderboards-refresh')
->withoutOverlapping();
->withoutOverlapping()
->runInBackground();
Schedule::job(new \App\Jobs\RebuildTrendingNovaCardsJob())
->hourlyAt(25)

View File

@@ -982,6 +982,7 @@ Route::middleware(['auth', 'admin.access'])
Route::get('/usernames/moderation', [AdminController::class, 'usernameQueue'])->name('usernames');
Route::get('/uploads', [AdminController::class, 'uploadQueue'])->name('uploads');
Route::get('/settings', [AdminController::class, 'settings'])->name('settings');
Route::middleware('admin.role')->get('/auth-audit', [AdminController::class, 'authAudit'])->name('auth-audit');
Route::middleware(['artwork.maturity.access'])
->prefix('ai-biography')

View File

@@ -8,8 +8,8 @@ function usage(): void
fwrite(STDERR, <<<TXT
Usage:
php scripts/{$script} --base=https://skinbase.top /@gregor /categories /wallpapers
php scripts/{$script} https://skinbase.top/@gregor https://skinbase.top/categories
php scripts/{$script} --base=https://skinbase.org /@gregor /categories /wallpapers
php scripts/{$script} https://skinbase.org/@gregor https://skinbase.org/categories
Classifications:
INERTIA_SSR id="app" + data-page + non-empty app HTML

View File

@@ -234,12 +234,14 @@ build_rsync_args() {
--exclude "bootstrap/cache/"
--exclude ".env"
--exclude "public/hot"
--exclude "public/sitemap.xml"
--exclude "public/sitemaps/"
--exclude "node_modules"
--exclude "public/files/"
--exclude "resources/lang/"
--exclude "storage/"
--exclude ".git/"
--exclude ".deploy/"
--exclude ".cursor/"
--exclude ".venv/"
--exclude "/var/php-tmp"
@@ -489,6 +491,22 @@ adopt_dir_into_shared() {
sync_dir_into_shared "$source_path" "$shared_path" "$exclude_scope"
}
adopt_file_into_shared() {
local source_path="$1"
local shared_path="$2"
[[ -f "$source_path" ]] || return 0
ensure_dir "$(dirname "$shared_path")"
if [[ ! -e "$shared_path" ]]; then
mv "$source_path" "$shared_path"
return 0
fi
cp -a "$source_path" "$shared_path"
}
link_shared_paths() {
local target_release="$1"
@@ -503,6 +521,9 @@ link_shared_paths() {
rm -rf "$target_release/public/files"
ln -sfn "${REMOTE_SHARED_ROOT}/public/files" "$target_release/public/files"
rm -f "$target_release/public/sitemap.xml"
ln -sfn "${REMOTE_SHARED_ROOT}/public/sitemaps/sitemap.xml" "$target_release/public/sitemap.xml"
rm -rf "$target_release/public/sitemaps"
ln -sfn "${REMOTE_SHARED_ROOT}/public/sitemaps" "$target_release/public/sitemaps"
@@ -532,6 +553,7 @@ if [[ -e "$REMOTE_FOLDER" && ! -L "$REMOTE_FOLDER" ]]; then
adopt_dir_into_shared "${legacy_release_path}/storage" "${REMOTE_SHARED_ROOT}/storage" "storage"
adopt_dir_into_shared "${legacy_release_path}/public/files" "${REMOTE_SHARED_ROOT}/public/files"
adopt_dir_into_shared "${legacy_release_path}/public/sitemaps" "${REMOTE_SHARED_ROOT}/public/sitemaps"
adopt_file_into_shared "${legacy_release_path}/public/sitemap.xml" "${REMOTE_SHARED_ROOT}/public/sitemaps/sitemap.xml"
adopt_dir_into_shared "${legacy_release_path}/var/php-tmp" "${REMOTE_SHARED_ROOT}/var/php-tmp"
adopt_dir_into_shared "${legacy_release_path}/var/php-sessions" "${REMOTE_SHARED_ROOT}/var/php-sessions"
@@ -935,6 +957,9 @@ link_shared_paths() {
rm -rf "$target_release/public/files"
ln -sfn "${REMOTE_SHARED_ROOT}/public/files" "$target_release/public/files"
rm -f "$target_release/public/sitemap.xml"
ln -sfn "${REMOTE_SHARED_ROOT}/public/sitemaps/sitemap.xml" "$target_release/public/sitemap.xml"
rm -rf "$target_release/public/sitemaps"
ln -sfn "${REMOTE_SHARED_ROOT}/public/sitemaps" "$target_release/public/sitemaps"

File diff suppressed because one or more lines are too long

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