chore: commit current workspace changes
This commit is contained in:
@@ -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 ?? ''));
|
||||
|
||||
Reference in New Issue
Block a user