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