whereRaw('LOWER(username) = ?', [$normalized])->first(); if (! $user) { $redirect = DB::table('username_redirects') ->whereRaw('LOWER(old_username) = ?', [$normalized]) ->value('new_username'); if ($redirect) { return redirect()->route('profile.show', ['username' => strtolower((string) $redirect)], 301); } abort(404); } if ($username !== strtolower((string) $user->username)) { return redirect()->route('profile.show', ['username' => strtolower((string) $user->username)], 301); } $tab = $this->normalizeProfileTab($request->query('tab')); if ($tab !== null) { return $this->redirectToProfileTab($request, (string) $user->username, $tab); } return $this->renderProfilePage($request, $user, 'Profile/ProfileShow', false, 'posts'); } public function showGalleryByUsername(Request $request, string $username) { $normalized = UsernamePolicy::normalize($username); $user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first(); if (! $user) { $redirect = DB::table('username_redirects') ->whereRaw('LOWER(old_username) = ?', [$normalized]) ->value('new_username'); if ($redirect) { return redirect()->route('profile.gallery', ['username' => strtolower((string) $redirect)], 301); } abort(404); } if ($username !== strtolower((string) $user->username)) { return redirect()->route('profile.gallery', ['username' => strtolower((string) $user->username)], 301); } return $this->renderProfilePage($request, $user, 'Profile/ProfileGallery', true); } public function showTabByUsername(Request $request, string $username, string $tab) { $normalized = UsernamePolicy::normalize($username); $user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first(); $normalizedTab = $this->normalizeProfileTab($tab); if ($normalizedTab === null) { abort(404); } if (! $user) { $redirect = DB::table('username_redirects') ->whereRaw('LOWER(old_username) = ?', [$normalized]) ->value('new_username'); if ($redirect) { return redirect()->route('profile.tab', [ 'username' => strtolower((string) $redirect), 'tab' => $normalizedTab, ], 301); } abort(404); } if ($username !== strtolower((string) $user->username)) { return redirect()->route('profile.tab', [ 'username' => strtolower((string) $user->username), 'tab' => $normalizedTab, ], 301); } if ($request->query->has('tab')) { return $this->redirectToProfileTab($request, (string) $user->username, $normalizedTab); } return $this->renderProfilePage($request, $user, 'Profile/ProfileShow', false, $normalizedTab); } public function legacyById(Request $request, int $id, ?string $username = null) { $user = User::query()->findOrFail($id); return redirect()->route('profile.show', ['username' => strtolower((string) $user->username)], 301); } public function legacyByUsername(Request $request, string $username) { return redirect()->route('profile.show', ['username' => UsernamePolicy::normalize($username)], 301); } /** Toggle follow/unfollow for the profile of $username (auth required). */ public function toggleFollow(Request $request, string $username): JsonResponse { $normalized = UsernamePolicy::normalize($username); $target = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail(); $actorId = (int) Auth::id(); if ($actorId === $target->id) { return response()->json(['error' => 'Cannot follow yourself.'], 422); } $following = $this->followService->toggle($actorId, (int) $target->id); $count = $this->followService->followersCount((int) $target->id); return response()->json([ 'following' => $following, 'follower_count' => $count, ]); } /** Store a comment on a user profile (auth required). */ public function storeComment(Request $request, string $username): RedirectResponse { $normalized = UsernamePolicy::normalize($username); $target = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail(); $request->validate([ 'body' => ['required', 'string', 'min:2', 'max:2000'], ]); $comment = ProfileComment::create([ 'profile_user_id' => $target->id, 'author_user_id' => Auth::id(), 'body' => $request->input('body'), ]); app(XPService::class)->awardCommentCreated((int) Auth::id(), (int) $comment->id, 'profile'); return Redirect::route('profile.show', ['username' => strtolower((string) $target->username)]) ->with('status', 'Comment posted!'); } public function edit(Request $request): View { $user = $request->user()->loadMissing(['profile', 'country']); $selectedCountry = $this->countryCatalog->resolveUserCountry($user); return view('profile.edit', [ 'user' => $user, 'countries' => $this->countryCatalog->profileSelectOptions(), 'selectedCountryId' => $selectedCountry?->id, ]); } /** * Inertia-powered profile edit page (Settings/ProfileEdit). */ public function editSettings(Request $request) { $user = $request->user()->loadMissing(['profile', 'country']); $cooldownDays = $this->usernameCooldownDays(); $lastUsernameChangeAt = $this->lastUsernameChangeAt($user); $usernameCooldownRemainingDays = 0; if ($lastUsernameChangeAt !== null) { $nextAllowedChangeAt = $lastUsernameChangeAt->copy()->addDays($cooldownDays); if ($nextAllowedChangeAt->isFuture()) { $usernameCooldownRemainingDays = now()->diffInDays($nextAllowedChangeAt); } } // Parse birth date parts $birthDay = null; $birthMonth = null; $birthYear = null; // Merge modern user_profiles data $profileData = []; try { if (Schema::hasTable('user_profiles')) { $profile = DB::table('user_profiles')->where('user_id', $user->id)->first(); if ($profile) { $profileData = (array) $profile; if (isset($profile->website)) $user->homepage = $profile->website; if (isset($profile->about)) $user->about_me = $profile->about; if (isset($profile->birthdate)) $user->birth = $profile->birthdate; if (isset($profile->gender)) $user->gender = $profile->gender; if (isset($profile->country_code)) $user->country_code = $profile->country_code; if (isset($profile->signature)) $user->signature = $profile->signature; if (isset($profile->description)) $user->description = $profile->description; if (isset($profile->mlist)) $user->mlist = $profile->mlist; if (isset($profile->friend_upload_notice)) $user->friend_upload_notice = $profile->friend_upload_notice; if (isset($profile->auto_post_upload)) $user->auto_post_upload = $profile->auto_post_upload; } } } catch (\Throwable $e) {} if (!empty($user->birth)) { try { $dt = \Carbon\Carbon::parse($user->birth); $birthDay = $dt->format('d'); $birthMonth = $dt->format('m'); $birthYear = $dt->format('Y'); } catch (\Throwable $e) {} } $selectedCountry = $this->countryCatalog->resolveUserCountry($user); $countries = $this->countryCatalog->profileSelectOptions(); // Avatar URL $avatarHash = $profileData['avatar_hash'] ?? $user->icon ?? null; $avatarUrl = !empty($avatarHash) ? AvatarUrl::forUser((int) $user->id, $avatarHash, 256) : AvatarUrl::default(); $emailNotifications = (bool) ($profileData['email_notifications'] ?? $profileData['mlist'] ?? $user->mlist ?? true); $uploadNotifications = (bool) ($profileData['upload_notifications'] ?? $profileData['friend_upload_notice'] ?? $user->friend_upload_notice ?? true); $followerNotifications = (bool) ($profileData['follower_notifications'] ?? true); $commentNotifications = (bool) ($profileData['comment_notifications'] ?? true); $newsletter = (bool) ($profileData['newsletter'] ?? $profileData['mlist'] ?? $user->mlist ?? false); return Inertia::render('Settings/ProfileEdit', [ 'user' => [ 'id' => $user->id, 'username' => $user->username, 'email' => $user->email, 'name' => $user->name, 'homepage' => $user->homepage ?? $user->website ?? null, 'about_me' => $user->about_me ?? $user->about ?? null, 'signature' => $user->signature ?? null, 'description' => $user->description ?? null, 'gender' => $user->gender ?? null, 'birthday' => $user->birth ?? null, 'country_id' => $selectedCountry?->id ?? $user->country_id ?? null, 'country_code' => $selectedCountry?->iso2 ?? $user->country_code ?? null, 'email_notifications' => $emailNotifications, 'upload_notifications' => $uploadNotifications, 'follower_notifications' => $followerNotifications, 'comment_notifications' => $commentNotifications, 'newsletter' => $newsletter, 'last_username_change_at' => $user->last_username_change_at, 'username_changed_at' => $user->username_changed_at, ], 'avatarUrl' => $avatarUrl, 'birthDay' => $birthDay, 'birthMonth' => $birthMonth, 'birthYear' => $birthYear, 'usernameCooldownDays' => $cooldownDays, 'usernameCooldownRemainingDays' => $usernameCooldownRemainingDays, 'usernameCooldownActive' => $usernameCooldownRemainingDays > 0, 'countries' => $countries, 'flash' => [ 'status' => session('status'), 'error' => session('error'), 'botCaptchaRequired' => session('bot_captcha_required', false), ], 'captcha' => $this->captchaVerifier->frontendConfig(), ])->rootView('settings'); } public function updateProfileSection(UpdateProfileSectionRequest $request, AvatarService $avatarService): RedirectResponse|JsonResponse { $user = $request->user(); $validated = $request->validated(); $user->name = (string) $validated['display_name']; $user->save(); $profileUpdates = [ 'website' => $validated['website'] ?? null, 'about' => $validated['bio'] ?? null, 'signature' => $validated['signature'] ?? null, 'description' => $validated['description'] ?? null, ]; $avatarUrl = AvatarUrl::forUser((int) $user->id, null, 256); if (!empty($validated['remove_avatar'])) { $avatarService->removeAvatar((int) $user->id); $avatarUrl = AvatarUrl::default(); } if ($request->hasFile('avatar')) { $hash = $avatarService->storeFromUploadedFile( (int) $user->id, $request->file('avatar'), (string) ($validated['avatar_position'] ?? 'center') ); $avatarUrl = AvatarUrl::forUser((int) $user->id, $hash, 256); } $this->persistProfileUpdates((int) $user->id, $profileUpdates); return $this->settingsResponse( $request, 'Profile updated successfully.', ['avatarUrl' => $avatarUrl] ); } public function updateAccountSection(UpdateAccountSectionRequest $request): RedirectResponse|JsonResponse { return $this->updateUsername($request); } public function updateUsername(UpdateAccountSectionRequest $request): RedirectResponse|JsonResponse { $user = $request->user(); $validated = $request->validated(); $incomingUsername = UsernamePolicy::normalize((string) $validated['username']); $currentUsername = UsernamePolicy::normalize((string) ($user->username ?? '')); if ($incomingUsername !== '' && $incomingUsername !== $currentUsername) { $similar = UsernamePolicy::similarReserved($incomingUsername); if ($similar !== null && ! UsernamePolicy::hasApprovedOverride($incomingUsername, (int) $user->id)) { $this->usernameApprovalService->submit($user, $incomingUsername, 'profile_update', [ 'current_username' => $currentUsername, ]); return $this->usernameValidationError($request, 'This username is too similar to a reserved name and requires manual approval.'); } $cooldownDays = $this->usernameCooldownDays(); $isAdmin = method_exists($user, 'isAdmin') ? $user->isAdmin() : false; $lastUsernameChangeAt = $this->lastUsernameChangeAt($user); if (! $isAdmin && $lastUsernameChangeAt !== null && $lastUsernameChangeAt->gt(now()->subDays($cooldownDays))) { $remainingDays = now()->diffInDays($lastUsernameChangeAt->copy()->addDays($cooldownDays)); return $this->usernameValidationError($request, "You can change your username again in {$remainingDays} days."); } $user->username = $incomingUsername; $user->username_changed_at = now(); if (Schema::hasColumn('users', 'last_username_change_at')) { $user->last_username_change_at = now(); } $this->storeUsernameHistory((int) $user->id, $currentUsername); $this->storeUsernameRedirect((int) $user->id, $currentUsername, $incomingUsername); } $user->save(); return $this->settingsResponse($request, 'Account updated successfully.'); } public function requestEmailChange(RequestEmailChangeRequest $request): RedirectResponse|JsonResponse { if (! Schema::hasTable('email_changes')) { return response()->json([ 'errors' => [ 'new_email' => ['Email change is not available right now.'], ], ], 422); } $user = $request->user(); $validated = $request->validated(); $newEmail = strtolower((string) $validated['new_email']); $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); $expiresInMinutes = 10; DB::table('email_changes')->where('user_id', (int) $user->id)->delete(); DB::table('email_changes')->insert([ 'user_id' => (int) $user->id, 'new_email' => $newEmail, 'verification_code' => hash('sha256', $code), 'expires_at' => now()->addMinutes($expiresInMinutes), 'created_at' => now(), 'updated_at' => now(), ]); Mail::to($newEmail)->queue(new EmailChangeVerificationCodeMail($code, $expiresInMinutes)); return $this->settingsResponse($request, 'Verification code sent to your new email address.'); } public function verifyEmailChange(VerifyEmailChangeRequest $request): RedirectResponse|JsonResponse { if (! Schema::hasTable('email_changes')) { return response()->json([ 'errors' => [ 'code' => ['Email change verification is not available right now.'], ], ], 422); } $user = $request->user(); $validated = $request->validated(); $codeHash = hash('sha256', (string) $validated['code']); $change = DB::table('email_changes') ->where('user_id', (int) $user->id) ->whereNull('used_at') ->orderByDesc('id') ->first(); if (! $change) { return response()->json(['errors' => ['code' => ['No pending email change request found.']]], 422); } if (now()->greaterThan($change->expires_at)) { DB::table('email_changes')->where('id', $change->id)->delete(); return response()->json(['errors' => ['code' => ['Verification code has expired. Please request a new one.']]], 422); } if (! hash_equals((string) $change->verification_code, $codeHash)) { return response()->json(['errors' => ['code' => ['Verification code is invalid.']]], 422); } $newEmail = strtolower((string) $change->new_email); $oldEmail = strtolower((string) ($user->email ?? '')); DB::transaction(function () use ($user, $change, $newEmail): void { $lockedUser = User::query()->whereKey((int) $user->id)->lockForUpdate()->firstOrFail(); $lockedUser->email = $newEmail; $lockedUser->email_verified_at = now(); $lockedUser->save(); DB::table('email_changes') ->where('id', (int) $change->id) ->update([ 'used_at' => now(), 'updated_at' => now(), ]); DB::table('email_changes') ->where('user_id', (int) $user->id) ->where('id', '!=', (int) $change->id) ->delete(); }); if ($oldEmail !== '' && $oldEmail !== $newEmail) { Mail::to($oldEmail)->queue(new EmailChangedSecurityAlertMail($newEmail)); } return $this->settingsResponse($request, 'Email updated successfully.', [ 'email' => $newEmail, ]); } public function updatePersonalSection(UpdatePersonalSectionRequest $request): RedirectResponse|JsonResponse { $validated = $request->validated(); $selectedCountry = $this->resolveCountrySelection($validated['country_id'] ?? null); $this->persistUserCountrySelection($request->user(), $selectedCountry); $profileUpdates = [ 'birthdate' => $validated['birthday'] ?? null, 'country_code' => $selectedCountry?->iso2, ]; if (!empty($validated['gender'])) { $profileUpdates['gender'] = strtoupper((string) $validated['gender']); } $this->persistProfileUpdates((int) $request->user()->id, $profileUpdates); return $this->settingsResponse($request, 'Personal details saved successfully.'); } public function updateNotificationsSection(UpdateNotificationsSectionRequest $request): RedirectResponse|JsonResponse { $validated = $request->validated(); $userId = (int) $request->user()->id; $profileUpdates = [ 'email_notifications' => (bool) $validated['email_notifications'], 'upload_notifications' => (bool) $validated['upload_notifications'], 'follower_notifications' => (bool) $validated['follower_notifications'], 'comment_notifications' => (bool) $validated['comment_notifications'], 'newsletter' => (bool) $validated['newsletter'], // Legacy compatibility mappings. 'mlist' => (bool) $validated['newsletter'], 'friend_upload_notice' => (bool) $validated['upload_notifications'], ]; $this->persistProfileUpdates($userId, $profileUpdates); return $this->settingsResponse($request, 'Notification settings saved successfully.'); } public function updateSecurityPassword(UpdateSecurityPasswordRequest $request): RedirectResponse|JsonResponse { $validated = $request->validated(); $user = $request->user(); $user->password = Hash::make((string) $validated['new_password']); $user->save(); return $this->settingsResponse($request, 'Password updated successfully.'); } private function settingsResponse(Request $request, string $message, array $payload = []): RedirectResponse|JsonResponse { if ($request->expectsJson()) { return response()->json([ 'success' => true, 'message' => $message, ...$payload, ]); } return Redirect::back()->with('status', $message); } private function persistProfileUpdates(int $userId, array $updates): void { if ($updates === [] || !Schema::hasTable('user_profiles')) { return; } $filtered = []; foreach ($updates as $column => $value) { if (Schema::hasColumn('user_profiles', $column)) { $filtered[$column] = $value; } } if ($filtered === []) { return; } DB::table('user_profiles')->updateOrInsert(['user_id' => $userId], $filtered); } private function resolveCountrySelection(int|string|null $countryId = null, ?string $countryCode = null): ?Country { if (is_numeric($countryId) && (int) $countryId > 0) { return $this->countryCatalog->findById((int) $countryId); } if ($countryCode !== null && trim($countryCode) !== '') { return $this->countryCatalog->findByIso2($countryCode); } return null; } private function persistUserCountrySelection(User $user, ?Country $country): void { if (! Schema::hasColumn('users', 'country_id')) { return; } $user->country_id = $country?->id; $user->save(); } private function usernameCooldownDays(): int { return max(1, (int) config('usernames.rename_cooldown_days', 30)); } private function lastUsernameChangeAt(User $user): ?\Illuminate\Support\Carbon { return $user->last_username_change_at ?? $user->username_changed_at; } private function usernameValidationError(Request $request, string $message): RedirectResponse|JsonResponse { $error = ['username' => [$message]]; if ($request->expectsJson()) { return response()->json(['errors' => $error], 422); } return Redirect::back()->withErrors($error); } private function storeUsernameHistory(int $userId, string $oldUsername): void { if ($oldUsername === '' || ! Schema::hasTable('username_history')) { return; } $payload = [ 'user_id' => $userId, 'old_username' => $oldUsername, 'created_at' => now(), ]; if (Schema::hasColumn('username_history', 'changed_at')) { $payload['changed_at'] = now(); } if (Schema::hasColumn('username_history', 'updated_at')) { $payload['updated_at'] = now(); } DB::table('username_history')->insert($payload); } private function storeUsernameRedirect(int $userId, string $oldUsername, string $newUsername): void { if ($oldUsername === '' || ! Schema::hasTable('username_redirects')) { return; } DB::table('username_redirects')->updateOrInsert( ['old_username' => $oldUsername], [ 'new_username' => $newUsername, 'user_id' => $userId, 'updated_at' => now(), 'created_at' => now(), ] ); } public function update(ProfileUpdateRequest $request, \App\Services\AvatarService $avatarService): RedirectResponse|JsonResponse { $user = $request->user(); $validated = $request->validated(); logger()->debug('Profile update validated data', $validated); if (isset($validated['name'])) { $user->name = $validated['name']; } if (array_key_exists('username', $validated)) { $incomingUsername = UsernamePolicy::normalize((string) $validated['username']); $currentUsername = UsernamePolicy::normalize((string) ($user->username ?? '')); if ($incomingUsername !== '' && $incomingUsername !== $currentUsername) { $similar = UsernamePolicy::similarReserved($incomingUsername); if ($similar !== null && ! UsernamePolicy::hasApprovedOverride($incomingUsername, (int) $user->id)) { $this->usernameApprovalService->submit($user, $incomingUsername, 'profile_update', [ 'current_username' => $currentUsername, ]); $error = ['username' => ['This username is too similar to a reserved name and requires manual approval.']]; if ($request->expectsJson()) { return response()->json(['errors' => $error], 422); } return Redirect::back()->withErrors($error); } $cooldownDays = $this->usernameCooldownDays(); $isAdmin = method_exists($user, 'isAdmin') ? $user->isAdmin() : false; $lastUsernameChangeAt = $this->lastUsernameChangeAt($user); if (! $isAdmin && $lastUsernameChangeAt !== null && $lastUsernameChangeAt->gt(now()->subDays($cooldownDays))) { $error = ['username' => ["Username can only be changed once every {$cooldownDays} days."]]; if ($request->expectsJson()) { return response()->json(['errors' => $error], 422); } return Redirect::back()->withErrors($error); } $user->username = $incomingUsername; $user->username_changed_at = now(); if (Schema::hasColumn('users', 'last_username_change_at')) { $user->last_username_change_at = now(); } $this->storeUsernameHistory((int) $user->id, $currentUsername); $this->storeUsernameRedirect((int) $user->id, $currentUsername, $incomingUsername); } } if (!empty($validated['email']) && empty($user->email)) { $user->email = $validated['email']; $user->email_verified_at = null; } $user->save(); $profileUpdates = []; if (!empty($validated['about'])) $profileUpdates['about'] = $validated['about']; if (!empty($validated['web'])) { $profileUpdates['website'] = $validated['web']; } elseif (!empty($validated['homepage'])) { $profileUpdates['website'] = $validated['homepage']; } $day = $validated['day'] ?? null; $month = $validated['month'] ?? null; $year = $validated['year'] ?? null; if ($year && $month && $day) { $profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int)$year, (int)$month, (int)$day); } if (!empty($validated['gender'])) { $g = strtolower($validated['gender']); $map = ['m' => 'M', 'f' => 'F', 'n' => 'X', 'x' => 'X']; $profileUpdates['gender'] = $map[$g] ?? strtoupper($validated['gender']); } if (array_key_exists('country_id', $validated) || array_key_exists('country', $validated)) { $selectedCountry = $this->resolveCountrySelection( $validated['country_id'] ?? null, $validated['country'] ?? null, ); $this->persistUserCountrySelection($user, $selectedCountry); $profileUpdates['country_code'] = $selectedCountry?->iso2; } if (array_key_exists('mailing', $validated)) { $profileUpdates['mlist'] = filter_var($validated['mailing'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0; } if (array_key_exists('notify', $validated)) { $profileUpdates['friend_upload_notice'] = filter_var($validated['notify'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0; } if (array_key_exists('auto_post_upload', $validated)) { $profileUpdates['auto_post_upload'] = filter_var($validated['auto_post_upload'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0; } if (isset($validated['signature'])) $profileUpdates['signature'] = $validated['signature']; if (isset($validated['description'])) $profileUpdates['description'] = $validated['description']; if (isset($validated['about'])) $profileUpdates['about'] = $validated['about']; if ($request->hasFile('avatar')) { try { $avatarService->storeFromUploadedFile($user->id, $request->file('avatar')); } catch (\Exception $e) { if ($request->expectsJson()) { return response()->json(['errors' => ['avatar' => ['Avatar processing failed: ' . $e->getMessage()]]], 422); } return Redirect::back()->with('error', 'Avatar processing failed: ' . $e->getMessage()); } } if ($request->hasFile('emoticon')) { $file = $request->file('emoticon'); $fname = $file->getClientOriginalName(); $path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname); try { \Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['eicon' => $fname]); } catch (\Exception $e) {} } if ($request->hasFile('photo')) { $file = $request->file('photo'); $fname = $file->getClientOriginalName(); $path = \Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname); if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) { $profileUpdates['cover_image'] = $fname; } else { try { \Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['picture' => $fname]); } catch (\Exception $e) {} } } try { if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) { if (!empty($profileUpdates)) { \Illuminate\Support\Facades\DB::table('user_profiles')->updateOrInsert(['user_id' => $user->id], $profileUpdates); } } else { if (!empty($profileUpdates)) { \Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update($profileUpdates); } } } catch (\Exception $e) { logger()->error('Profile update error: '.$e->getMessage()); } if ($request->expectsJson()) { return response()->json(['success' => true]); } return Redirect::route('dashboard.profile')->with('status', 'profile-updated'); } public function destroy(Request $request): RedirectResponse|JsonResponse { $bag = $request->expectsJson() ? 'default' : 'userDeletion'; $request->validateWithBag($bag, [ 'password' => ['required', 'current_password'], ]); $user = $request->user(); Auth::logout(); $user->delete(); $request->session()->invalidate(); $request->session()->regenerateToken(); if ($request->expectsJson()) { return response()->json(['success' => true]); } return Redirect::to('/'); } public function password(Request $request): RedirectResponse|JsonResponse { $request->validate([ 'current_password' => ['required', 'current_password'], 'password' => ['required', 'confirmed', PasswordRule::min(8)], ]); $user = $request->user(); $user->password = Hash::make($request->input('password')); $user->save(); if ($request->expectsJson()) { return response()->json(['success' => true]); } return Redirect::route('dashboard.profile')->with('status', 'password-updated'); } private function renderProfilePage( Request $request, User $user, string $component = 'Profile/ProfileShow', bool $galleryOnly = false, ?string $initialTab = null, ) { $isOwner = Auth::check() && Auth::id() === $user->id; $viewer = Auth::user(); $perPage = 24; // ── Artworks (cursor-paginated) ────────────────────────────────────── $artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage) ->through(function (Artwork $art) { return (object) $this->mapArtworkCardPayload($art); }); // ── Featured artworks for this user ───────────────────────────────── $featuredArtworks = collect(); if (Schema::hasTable('artwork_features')) { $featuredArtworks = DB::table('artwork_features as af') ->join('artworks as a', 'a.id', '=', 'af.artwork_id') ->where('a.user_id', $user->id) ->where('af.is_active', true) ->whereNull('af.deleted_at') ->whereNull('a.deleted_at') ->where('a.is_public', true) ->where('a.is_approved', true) ->orderByDesc('af.featured_at') ->limit(3) ->select([ 'a.id', 'a.title as name', 'a.hash', 'a.thumb_ext', 'a.width', 'a.height', 'af.label', 'af.featured_at', ]) ->get() ->map(function ($row) { $thumbUrl = ($row->hash && $row->thumb_ext) ? ThumbnailService::fromHash($row->hash, $row->thumb_ext, 'md') : '/images/placeholder.jpg'; return (object) [ 'id' => $row->id, 'name' => $row->name, 'thumb' => $thumbUrl, 'label' => $row->label, 'featured_at' => $row->featured_at, 'width' => $row->width, 'height' => $row->height, ]; }); } // ── Favourites ─────────────────────────────────────────────────────── $favouriteLimit = 12; $favouriteTable = $this->resolveFavouriteTable(); $favourites = [ 'data' => [], 'next_cursor' => null, ]; if ($favouriteTable !== null) { $favIds = DB::table($favouriteTable . ' as af') ->join('artworks as a', 'a.id', '=', 'af.artwork_id') ->where('af.user_id', $user->id) ->whereNull('a.deleted_at') ->where('a.is_public', true) ->where('a.is_approved', true) ->whereNotNull('a.published_at') ->orderByDesc('af.created_at') ->orderByDesc('af.artwork_id') ->limit($favouriteLimit + 1) ->pluck('a.id'); if ($favIds->isNotEmpty()) { $hasMore = $favIds->count() > $favouriteLimit; $favIds = $favIds->take($favouriteLimit); $indexed = Artwork::with('user:id,name,username') ->whereIn('id', $favIds) ->get() ->keyBy('id'); $favourites = [ 'data' => $favIds ->filter(fn ($id) => $indexed->has($id)) ->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id])) ->values() ->all(), 'next_cursor' => $hasMore ? base64_encode((string) $favouriteLimit) : null, ]; } } // ── Statistics ─────────────────────────────────────────────────────── $stats = null; if (Schema::hasTable('user_statistics')) { $stats = DB::table('user_statistics')->where('user_id', $user->id)->first(); } // ── Social links ───────────────────────────────────────────────────── $socialLinks = collect(); if (Schema::hasTable('user_social_links')) { $socialLinks = DB::table('user_social_links') ->where('user_id', $user->id) ->get() ->keyBy('platform'); } // ── Follower data ──────────────────────────────────────────────────── $followerCount = 0; $recentFollowers = collect(); $viewerIsFollowing = false; $followingCount = 0; if (Schema::hasTable('user_followers')) { $followerCount = DB::table('user_followers')->where('user_id', $user->id)->count(); $followingCount = DB::table('user_followers')->where('follower_id', $user->id)->count(); $recentFollowers = DB::table('user_followers as uf') ->join('users as u', 'u.id', '=', 'uf.follower_id') ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') ->where('uf.user_id', $user->id) ->whereNull('u.deleted_at') ->orderByDesc('uf.created_at') ->limit(10) ->select(['u.id', 'u.username', 'u.name', 'up.avatar_hash', 'uf.created_at as followed_at']) ->get() ->map(fn ($row) => (object) [ 'id' => $row->id, 'username' => $row->username, 'uname' => $row->username ?? $row->name, 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50), 'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)), 'followed_at' => $row->followed_at, ]); if ($viewer && $viewer->id !== $user->id) { $viewerIsFollowing = DB::table('user_followers') ->where('user_id', $user->id) ->where('follower_id', $viewer->id) ->exists(); } } $liveUploadsCount = 0; if (Schema::hasTable('artworks')) { $liveUploadsCount = (int) DB::table('artworks') ->where('user_id', $user->id) ->whereNull('deleted_at') ->count(); } $liveAwardsReceivedCount = 0; if (Schema::hasTable('artwork_awards') && Schema::hasTable('artworks')) { $liveAwardsReceivedCount = (int) DB::table('artwork_awards as aw') ->join('artworks as a', 'a.id', '=', 'aw.artwork_id') ->where('a.user_id', $user->id) ->whereNull('a.deleted_at') ->count(); } $statsPayload = array_merge($stats ? (array) $stats : [], [ 'uploads_count' => $liveUploadsCount, 'awards_received_count' => $liveAwardsReceivedCount, 'followers_count' => (int) $followerCount, 'following_count' => (int) $followingCount, ]); // ── Profile comments ───────────────────────────────────────────────── $profileComments = collect(); if (Schema::hasTable('profile_comments')) { $profileComments = DB::table('profile_comments as pc') ->join('users as u', 'u.id', '=', 'pc.author_user_id') ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') ->where('pc.profile_user_id', $user->id) ->where('pc.is_active', true) ->whereNull('u.deleted_at') ->orderByDesc('pc.created_at') ->limit(10) ->select([ 'pc.id', 'pc.body', 'pc.created_at', 'u.id as author_id', 'u.username as author_username', 'u.name as author_name', 'u.level as author_level', 'u.rank as author_rank', 'up.avatar_hash as author_avatar_hash', 'up.signature as author_signature', ]) ->get() ->map(fn ($row) => (object) [ 'id' => $row->id, 'body' => $row->body, 'created_at' => $row->created_at, 'author_id' => $row->author_id, 'author_name' => $row->author_username ?? $row->author_name ?? 'Unknown', 'author_level' => (int) ($row->author_level ?? 1), 'author_rank' => (string) ($row->author_rank ?? 'Newbie'), 'author_profile_url' => '/@' . strtolower((string) ($row->author_username ?? $row->author_id)), 'author_avatar' => AvatarUrl::forUser((int) $row->author_id, $row->author_avatar_hash, 50), 'author_signature' => $row->author_signature, ]); } $xpSummary = $this->xp->summary((int) $user->id); $followContext = $viewer && $viewer->id !== $user->id ? $this->followService->relationshipContext((int) $viewer->id, (int) $user->id) : null; $followAnalytics = $this->followAnalytics->summaryForUser((int) $user->id, $followerCount); $suggestedUsers = $viewer ? $this->userSuggestions->suggestFor($viewer, 4) : []; $creatorStories = Story::query() ->published() ->with(['tags']) ->where('creator_id', $user->id) ->latest('published_at') ->limit(6) ->get([ 'id', 'slug', 'title', 'excerpt', 'cover_image', 'reading_time', 'views', 'likes_count', 'comments_count', 'published_at', ]) ->map(fn (Story $story) => [ 'id' => $story->id, 'slug' => $story->slug, 'title' => $story->title, 'excerpt' => $story->excerpt, 'cover_url' => $story->cover_url, 'reading_time' => $story->reading_time, 'views' => (int) $story->views, 'likes_count' => (int) $story->likes_count, 'comments_count' => (int) $story->comments_count, 'creator_level' => $xpSummary['level'], 'creator_rank' => $xpSummary['rank'], 'published_at' => $story->published_at?->toISOString(), ]); $profileCollections = $this->collections->getProfileCollections($user, $viewer); $profileCollectionsPayload = $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner); // ── Profile data ───────────────────────────────────────────────────── $profile = $user->profile; $country = $this->countryCatalog->resolveUserCountry($user); $countryCode = $country?->iso2 ?? $profile?->country_code; $countryName = $country?->name_common; if ($countryName === null && $profile?->country_code) { $countryName = strtoupper((string) $profile->country_code); } // ── Cover image hero (preferred) ──────────────────────────────────── $heroBgUrl = CoverUrl::forUser($user->cover_hash, $user->cover_ext, $user->updated_at?->timestamp ?? time()); // ── Increment profile views (async-safe, ignore errors) ────────────── if (! $isOwner) { try { $this->userStats->incrementProfileViews($user->id); } catch (\Throwable) {} } // ── Normalise artworks for JSON serialisation ──────────────────── $artworkItems = collect($artworks->items())->values(); $artworkPayload = [ 'data' => $artworkItems, 'next_cursor' => $artworks->nextCursor()?->encode(), 'has_more' => $artworks->hasMorePages(), ]; // ── Avatar URL on user object ──────────────────────────────────── $avatarUrl = AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 128); // ── Auth context for JS ─────────────────────────────────────────── $authData = null; if (Auth::check()) { /** @var \App\Models\User $authUser */ $authUser = Auth::user(); $authAvatarUrl = AvatarUrl::forUser((int) $authUser->id, $authUser->profile?->avatar_hash, 64); $authData = [ 'user' => [ 'id' => $authUser->id, 'username' => $authUser->username, 'name' => $authUser->name, 'avatar' => $authAvatarUrl, ], ]; } $usernameSlug = strtolower((string) ($user->username ?? '')); $canonical = url('/@' . $usernameSlug); $galleryUrl = url('/@' . $usernameSlug . '/gallery'); $profileTabUrls = collect(self::PROFILE_TABS) ->mapWithKeys(fn (string $tab) => [$tab => url('/@' . $usernameSlug . '/' . $tab)]) ->all(); $achievementSummary = $this->achievements->summary((int) $user->id); $leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id); $resolvedInitialTab = $this->normalizeProfileTab($initialTab); $isTabLanding = ! $galleryOnly && $resolvedInitialTab !== null; $activeProfileUrl = $resolvedInitialTab !== null ? ($profileTabUrls[$resolvedInitialTab] ?? $canonical) : $canonical; $tabMetaLabel = $resolvedInitialTab !== null ? ucfirst($resolvedInitialTab) : null; return Inertia::render($component, [ 'user' => [ 'id' => $user->id, 'username' => $user->username, 'name' => $user->name, 'avatar_url' => $avatarUrl, 'cover_url' => $heroBgUrl, 'cover_position'=> (int) ($user->cover_position ?? 50), 'created_at' => $user->created_at?->toISOString(), 'last_visit_at' => $user->last_visit_at ? (string) $user->last_visit_at : null, 'xp' => $xpSummary['xp'], 'level' => $xpSummary['level'], 'rank' => $xpSummary['rank'], 'next_level_xp' => $xpSummary['next_level_xp'], 'current_level_xp' => $xpSummary['current_level_xp'], 'progress_percent' => $xpSummary['progress_percent'], 'max_level' => $xpSummary['max_level'], ], 'profile' => $profile ? [ 'about' => $profile->about ?? null, 'website' => $profile->website ?? null, 'country_code' => $countryCode, 'gender' => $profile->gender ?? null, 'birthdate' => $profile->birthdate ?? null, 'cover_image' => $profile->cover_image ?? null, ] : null, 'artworks' => $artworkPayload, 'featuredArtworks' => $featuredArtworks->values(), 'favourites' => $favourites, 'stats' => $statsPayload, 'socialLinks' => $socialLinks, 'followerCount' => $followerCount, 'recentFollowers' => $recentFollowers->values(), 'followContext' => $followContext, 'followAnalytics' => $followAnalytics, 'suggestedUsers' => $suggestedUsers, 'viewerIsFollowing' => $viewerIsFollowing, 'heroBgUrl' => $heroBgUrl, 'profileComments' => $profileComments->values(), 'creatorStories' => $creatorStories->values(), 'collections' => $profileCollectionsPayload, 'achievements' => $achievementSummary, 'leaderboardRank' => $leaderboardRank, 'countryName' => $countryName, 'isOwner' => $isOwner, 'auth' => $authData, 'initialTab' => $resolvedInitialTab, 'profileUrl' => $canonical, 'galleryUrl' => $galleryUrl, 'collectionCreateUrl' => $isOwner ? route('settings.collections.create') : null, 'collectionReorderUrl' => $isOwner ? route('settings.collections.reorder-profile') : null, 'collectionsFeaturedUrl' => route('collections.featured'), 'collectionFeatureLimit' => (int) config('collections.featured_limit', 3), 'profileTabUrls' => $profileTabUrls, ])->withViewData([ 'page_title' => $galleryOnly ? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase') : ($isTabLanding ? (($user->username ?? $user->name ?? 'User') . ' ' . $tabMetaLabel . ' on Skinbase') : (($user->username ?? $user->name ?? 'User') . ' on Skinbase')), 'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl, 'page_meta_description' => $galleryOnly ? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.') : ($isTabLanding ? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.') : ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.')), 'og_image' => $avatarUrl, ]); } private function normalizeProfileTab(mixed $tab): ?string { if (! is_string($tab)) { return null; } $normalized = strtolower(trim($tab)); return in_array($normalized, self::PROFILE_TABS, true) ? $normalized : null; } private function redirectToProfileTab(Request $request, string $username, string $tab): RedirectResponse { $baseUrl = url('/@' . strtolower($username) . '/' . $tab); $query = $request->query(); unset($query['tab']); if ($query !== []) { $baseUrl .= '?' . http_build_query($query); } return redirect()->to($baseUrl, 301); } private function resolveFavouriteTable(): ?string { foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) { if (Schema::hasTable($table)) { return $table; } } return null; } /** * @return array */ private function mapArtworkCardPayload(Artwork $art): array { $present = ThumbnailPresenter::present($art, 'md'); $category = $art->categories->first(); $contentType = $category?->contentType; $stats = $art->stats; return [ 'id' => $art->id, 'name' => $art->title, 'picture' => $art->file_name, 'datum' => $this->formatIsoDate($art->published_at), 'published_at' => $this->formatIsoDate($art->published_at), 'thumb' => $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'], 'uname' => $art->user->name ?? 'Skinbase', 'username' => $art->user->username ?? null, 'user_id' => $art->user_id, 'author_level' => (int) ($art->user?->level ?? 1), 'author_rank' => (string) ($art->user?->rank ?? 'Newbie'), 'content_type' => $contentType?->name, 'content_type_slug' => $contentType?->slug, 'category' => $category?->name, 'category_slug' => $category?->slug, 'views' => (int) ($stats?->views ?? $art->view_count ?? 0), 'downloads' => (int) ($stats?->downloads ?? 0), 'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0), 'width' => $art->width, 'height' => $art->height, ]; } private function formatIsoDate(mixed $value): ?string { if ($value instanceof CarbonInterface) { return $value->toISOString(); } if ($value instanceof \DateTimeInterface) { return $value->format(DATE_ATOM); } return is_string($value) ? $value : null; } }