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
@@ -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
@@ -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 !== '')));
@@ -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';
}
@@ -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)]">
@@ -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"
@@ -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
@@ -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')
@@ -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
@@ -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')
@@ -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,
));
@@ -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;
}
}
@@ -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();
}
}
@@ -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'],
],
]);
}
}
@@ -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
@@ -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');
}
/**
@@ -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;
@@ -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);
@@ -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)]);
}
}
@@ -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)]);
}
}
@@ -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;
+6 -2
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
@@ -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);
}
@@ -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
{
@@ -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);
+23 -6
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();
@@ -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);
}
+23
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);
}
}
+1 -1
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);
@@ -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;
}
}
+35
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,
+56 -43
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')) {
+10 -2
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
);
}
}
+1 -5
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',
],
);
}
+35
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);
}
}
+26
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'));
+42
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);
}
}
+18 -1
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
@@ -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 !== '')));
@@ -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';
}
@@ -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)
+11 -1
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;
+20 -1
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 ?? ''));
+2
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,
@@ -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
-128560
View File
File diff suppressed because one or more lines are too long
+6
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'), '/'),
],
],
];
+2
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),
@@ -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]
@@ -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');
});
@@ -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');
}
};
+1 -1
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
View File
Binary file not shown.
Binary file not shown.
+51
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
+17
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
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
+6
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>
+250
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>
+5
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
+2
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>
File diff suppressed because it is too large Load Diff
+20
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>
+22
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>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -6
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">
+232
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>
)
}
@@ -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)]">
@@ -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)]">
+5
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__/**',
+1
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"
+1
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
-27
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)
},
})
@@ -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>
+1 -1
View File
@@ -3,7 +3,7 @@
@php
use App\Banner;
$src = $sourceArtwork;
$useUnifiedSeo = true;
$useUnifiedSeo = false;
@endphp
@push('head')
+7 -1
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')
@@ -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
+1
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)
+1 -1
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 */
+1
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')
@@ -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
+4 -7
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)
+1
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')
+2 -2
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
+25
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