chore: commit current workspace changes
This commit is contained in:
@@ -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,
|
||||
));
|
||||
|
||||
34
app/Console/Commands/RefreshLeaderboardsCommand.php
Normal file
34
app/Console/Commands/RefreshLeaderboardsCommand.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\LeaderboardService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RefreshLeaderboardsCommand extends Command
|
||||
{
|
||||
protected $signature = 'leaderboards:refresh';
|
||||
|
||||
protected $description = 'Refresh all leaderboard rows and clear leaderboard caches.';
|
||||
|
||||
public function __construct(private readonly LeaderboardService $leaderboards)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Refreshing leaderboards …');
|
||||
|
||||
$results = $this->leaderboards->refreshAll();
|
||||
$updated = collect($results)
|
||||
->flatten(1)
|
||||
->sum(fn (int $count): int => $count);
|
||||
|
||||
$this->info("Done. Updated: {$updated} leaderboard row(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
162
app/Console/Commands/SendUserVerificationEmailCommand.php
Normal file
162
app/Console/Commands/SendUserVerificationEmailCommand.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\SendVerificationEmailJob;
|
||||
use App\Mail\RegistrationVerificationMail;
|
||||
use App\Models\EmailSendEvent;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\RegistrationEmailQuotaService;
|
||||
use App\Services\Auth\RegistrationVerificationTokenService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class SendUserVerificationEmailCommand extends Command
|
||||
{
|
||||
protected $signature = 'user:send-verification-email
|
||||
{userId : The user ID that should receive the verification email}
|
||||
{--now : Send immediately instead of queueing the existing verification job}
|
||||
{--force : Allow sending even if the user is already verified}';
|
||||
|
||||
protected $description = 'Send the registration verification email to a specific user ID.';
|
||||
|
||||
public function __construct(
|
||||
private readonly RegistrationVerificationTokenService $tokenService,
|
||||
private readonly RegistrationEmailQuotaService $quotaService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$userId = (int) $this->argument('userId');
|
||||
|
||||
if ($userId < 1) {
|
||||
$this->error('The user ID must be a positive integer.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$user = User::query()->find($userId);
|
||||
|
||||
if (! $user) {
|
||||
$this->error("User {$userId} was not found.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$email = strtolower(trim((string) $user->email));
|
||||
|
||||
if ($email === '') {
|
||||
$this->error("User {$userId} does not have an email address.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($user->email_verified_at !== null && ! $this->option('force')) {
|
||||
$this->error("User {$userId} already has a verified email address. Use --force to send anyway.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$token = $this->tokenService->createForUser($userId);
|
||||
|
||||
$event = EmailSendEvent::query()->create([
|
||||
'type' => 'verify_email',
|
||||
'email' => $email,
|
||||
'ip' => null,
|
||||
'user_id' => $userId,
|
||||
'status' => $this->option('now') ? 'pending' : 'queued',
|
||||
'reason' => null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
if ($this->option('now')) {
|
||||
return $this->sendNow($user, $event, $token);
|
||||
}
|
||||
|
||||
SendVerificationEmailJob::dispatch(
|
||||
emailEventId: (int) $event->id,
|
||||
email: $email,
|
||||
token: $token,
|
||||
userId: $userId,
|
||||
ip: null,
|
||||
);
|
||||
|
||||
$this->markVerificationEmailSent($user);
|
||||
|
||||
$this->info("Queued verification email for user {$userId} <{$email}>.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function sendNow(User $user, EmailSendEvent $event, string $token): int
|
||||
{
|
||||
if (! $this->acquireGlobalSendSlot()) {
|
||||
$this->updateEvent($event, 'blocked', 'rate_limited');
|
||||
$this->error('The global verification email rate limit is currently exhausted. Try again in a minute.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->quotaService->isExceeded()) {
|
||||
$this->updateEvent($event, 'blocked', 'quota');
|
||||
$this->error('The monthly registration email quota is exceeded.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
Mail::to($user->email)->send(new RegistrationVerificationMail($token));
|
||||
} catch (\Throwable $exception) {
|
||||
$this->updateEvent($event, 'failed', 'send_error');
|
||||
$this->error('Failed to send the verification email: ' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->quotaService->incrementSentCount();
|
||||
$this->updateEvent($event, 'sent', null);
|
||||
$this->markVerificationEmailSent($user);
|
||||
|
||||
$email = strtolower(trim((string) $user->email));
|
||||
$this->info("Sent verification email to user {$user->id} <{$email}>.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function acquireGlobalSendSlot(): bool
|
||||
{
|
||||
$key = 'registration:verification-email:global';
|
||||
$maxPerMinute = max(1, (int) config('registration.email_global_send_per_minute', 30));
|
||||
|
||||
return RateLimiter::attempt($key, $maxPerMinute, static fn () => true, 60);
|
||||
}
|
||||
|
||||
private function updateEvent(EmailSendEvent $event, string $status, ?string $reason): void
|
||||
{
|
||||
EmailSendEvent::query()
|
||||
->whereKey($event->getKey())
|
||||
->update([
|
||||
'status' => $status,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
private function markVerificationEmailSent(User $user): void
|
||||
{
|
||||
$now = now();
|
||||
|
||||
$windowStartedAt = $user->verification_send_window_started_at;
|
||||
if (! $windowStartedAt || $windowStartedAt->lt($now->copy()->subDay())) {
|
||||
$user->verification_send_window_started_at = $now;
|
||||
$user->verification_send_count_24h = 1;
|
||||
} else {
|
||||
$user->verification_send_count_24h = ((int) $user->verification_send_count_24h) + 1;
|
||||
}
|
||||
|
||||
$user->last_verification_sent_at = $now;
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
app/Http/Middleware/EnsureAdminRole.php
Normal file
23
app/Http/Middleware/EnsureAdminRole.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
app/Models/AuthAuditLog.php
Normal file
35
app/Models/AuthAuditLog.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
app/Services/Auth/AuthAuditLogger.php
Normal file
42
app/Services/Auth/AuthAuditLogger.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? ''));
|
||||
|
||||
@@ -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, """)}" />
|
||||
</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
bootstrap/ssr/ssr.js
128560
bootstrap/ssr/ssr.js
File diff suppressed because one or more lines are too long
@@ -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'), '/'),
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -195,7 +195,7 @@ To enable it on production:
|
||||
2. Add `fastcgi_intercept_errors on;` to every FastCGI location that should use the static fallback.
|
||||
3. Keep the static file available in the live public path so nginx can serve it without Laravel.
|
||||
|
||||
On the current `skinbase.top` vhost, the required FastCGI locations are:
|
||||
On the current `skinbase.org` vhost, the required FastCGI locations are:
|
||||
|
||||
- `location ^~ /api/uploads/`
|
||||
- `location = /index.php`
|
||||
|
||||
BIN
new_passwords.7z
Normal file
BIN
new_passwords.7z
Normal file
Binary file not shown.
BIN
projekti_2026_skinbase.7z
Normal file
BIN
projekti_2026_skinbase.7z
Normal file
Binary file not shown.
51
public/sitemap.xml
Normal file
51
public/sitemap.xml
Normal 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>
|
||||
80002
public/sitemaps/artworks-0001.xml
Normal file
80002
public/sitemaps/artworks-0001.xml
Normal file
File diff suppressed because it is too large
Load Diff
79996
public/sitemaps/artworks-0002.xml
Normal file
79996
public/sitemaps/artworks-0002.xml
Normal file
File diff suppressed because it is too large
Load Diff
79983
public/sitemaps/artworks-0003.xml
Normal file
79983
public/sitemaps/artworks-0003.xml
Normal file
File diff suppressed because it is too large
Load Diff
79853
public/sitemaps/artworks-0004.xml
Normal file
79853
public/sitemaps/artworks-0004.xml
Normal file
File diff suppressed because it is too large
Load Diff
77942
public/sitemaps/artworks-0005.xml
Normal file
77942
public/sitemaps/artworks-0005.xml
Normal file
File diff suppressed because it is too large
Load Diff
17
public/sitemaps/artworks-index.xml
Normal file
17
public/sitemaps/artworks-index.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/artworks-0001.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/artworks-0002.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/artworks-0003.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/artworks-0004.xml</loc>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>http://skinbase26.test/sitemaps/artworks-0005.xml</loc>
|
||||
</sitemap>
|
||||
</sitemapindex>
|
||||
18
public/sitemaps/cards.xml
Normal file
18
public/sitemaps/cards.xml
Normal 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>
|
||||
2351
public/sitemaps/categories.xml
Normal file
2351
public/sitemaps/categories.xml
Normal file
File diff suppressed because it is too large
Load Diff
6
public/sitemaps/collections.xml
Normal file
6
public/sitemaps/collections.xml
Normal 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
public/sitemaps/forum-categories.xml
Normal file
250
public/sitemaps/forum-categories.xml
Normal 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
public/sitemaps/forum-index.xml
Normal file
5
public/sitemaps/forum-index.xml
Normal 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>
|
||||
10910
public/sitemaps/forum-threads.xml
Normal file
10910
public/sitemaps/forum-threads.xml
Normal file
File diff suppressed because it is too large
Load Diff
2
public/sitemaps/news-google.xml
Normal file
2
public/sitemaps/news-google.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">
|
||||
</urlset>
|
||||
2002
public/sitemaps/news.xml
Normal file
2002
public/sitemaps/news.xml
Normal file
File diff suppressed because it is too large
Load Diff
20
public/sitemaps/static-pages.xml
Normal file
20
public/sitemaps/static-pages.xml
Normal 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
public/sitemaps/stories.xml
Normal file
22
public/sitemaps/stories.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
|
||||
<url>
|
||||
<loc>http://skinbase26.test/stories/skinbase-nova-is-here</loc>
|
||||
<lastmod>2026-04-09T12:27:18+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://skinbase26.test/stories/untitled-story</loc>
|
||||
<lastmod>2026-03-20T16:59:40+00:00</lastmod>
|
||||
<image:image>
|
||||
<image:loc>https://miro.medium.com/v2/resize:fit:1400/format:webp/1*9VkZ5qvofC2cAIxgTcQPuw.jpeg</image:loc>
|
||||
<image:title>sqlBackup: A Modern, Modular Solution for MySQL Backups</image:title>
|
||||
</image:image>
|
||||
</url>
|
||||
<url>
|
||||
<loc>http://skinbase26.test/stories/untitled-story-2</loc>
|
||||
<lastmod>2026-03-21T20:15:04+00:00</lastmod>
|
||||
<image:image>
|
||||
<image:loc>http://thumb.skinbase.org/photo/i5n_6.jpg</image:loc>
|
||||
<image:title>Wallpapers for Computer Desktop: Make Your Screen Look Better Every Day</image:title>
|
||||
</image:image>
|
||||
</url>
|
||||
</urlset>
|
||||
59150
public/sitemaps/tags.xml
Normal file
59150
public/sitemaps/tags.xml
Normal file
File diff suppressed because it is too large
Load Diff
9870
public/sitemaps/users.xml
Normal file
9870
public/sitemaps/users.xml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
resources/js/Pages/Admin/AuthAudit.jsx
Normal file
232
resources/js/Pages/Admin/AuthAudit.jsx
Normal 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)]">
|
||||
|
||||
@@ -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__/**',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@php
|
||||
use App\Banner;
|
||||
$src = $sourceArtwork;
|
||||
$useUnifiedSeo = true;
|
||||
$useUnifiedSeo = false;
|
||||
@endphp
|
||||
|
||||
@push('head')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
$commentsCollection = $comments ?? collect();
|
||||
$commentsCount = $commentsCount ?? $commentsCollection->count();
|
||||
$viewer = auth()->user();
|
||||
$errors = $errors ?? new \Illuminate\Support\ViewErrorBag();
|
||||
@endphp
|
||||
|
||||
@if($isPreview)
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
65
skinbase.top-20260429T075355.html
Normal file
65
skinbase.top-20260429T075355.html
Normal file
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
Reference in New Issue
Block a user