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); } return $this->renderUserProfile($request, $user); } 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(); $viewerId = Auth::id(); if ($viewerId === $target->id) { return response()->json(['error' => 'Cannot follow yourself.'], 422); } $exists = DB::table('user_followers') ->where('user_id', $target->id) ->where('follower_id', $viewerId) ->exists(); if ($exists) { DB::table('user_followers') ->where('user_id', $target->id) ->where('follower_id', $viewerId) ->delete(); $following = false; } else { DB::table('user_followers')->insertOrIgnore([ 'user_id' => $target->id, 'follower_id'=> $viewerId, 'created_at' => now(), ]); $following = true; } $count = DB::table('user_followers')->where('user_id', $target->id)->count(); 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'], ]); ProfileComment::create([ 'profile_user_id' => $target->id, 'author_user_id' => Auth::id(), 'body' => $request->input('body'), ]); return Redirect::route('profile.show', ['username' => strtolower((string) $target->username)]) ->with('status', 'Comment posted!'); } public function edit(Request $request): View { return view('profile.edit', [ 'user' => $request->user(), ]); } public function update(ProfileUpdateRequest $request, \App\Services\AvatarService $avatarService): RedirectResponse { $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, ]); return Redirect::back()->withErrors([ 'username' => 'This username is too similar to a reserved name and requires manual approval.', ]); } $cooldownDays = (int) config('usernames.rename_cooldown_days', 90); $isAdmin = method_exists($user, 'isAdmin') ? $user->isAdmin() : false; if (! $isAdmin && $user->username_changed_at !== null && $user->username_changed_at->gt(now()->subDays($cooldownDays))) { return Redirect::back()->withErrors([ 'username' => "Username can only be changed once every {$cooldownDays} days.", ]); } $user->username = $incomingUsername; $user->username_changed_at = now(); DB::table('username_history')->insert([ 'user_id' => (int) $user->id, 'old_username' => $currentUsername, 'changed_at' => now(), 'created_at' => now(), 'updated_at' => now(), ]); if ($currentUsername !== '') { DB::table('username_redirects')->updateOrInsert( ['old_username' => $currentUsername], [ 'new_username' => $incomingUsername, 'user_id' => (int) $user->id, 'updated_at' => now(), 'created_at' => now(), ] ); } } } 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 (!empty($validated['country'])) $profileUpdates['country_code'] = $validated['country']; 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 (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) { 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()); } return Redirect::route('dashboard.profile')->with('status', 'profile-updated'); } public function destroy(Request $request): RedirectResponse { $request->validateWithBag('userDeletion', [ 'password' => ['required', 'current_password'], ]); $user = $request->user(); Auth::logout(); $user->delete(); $request->session()->invalidate(); $request->session()->regenerateToken(); return Redirect::to('/'); } public function password(Request $request): RedirectResponse { $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(); return Redirect::route('dashboard.profile')->with('status', 'password-updated'); } private function renderUserProfile(Request $request, User $user) { $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) { $present = ThumbnailPresenter::present($art, 'md'); return (object) [ 'id' => $art->id, 'name' => $art->title, 'picture' => $art->file_name, 'datum' => $art->published_at, 'published_at' => $art->published_at, // required by cursor paginator (orders by this column) 'thumb' => $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'], 'uname' => $art->user->name ?? 'Skinbase', 'username' => $art->user->username ?? null, 'user_id' => $art->user_id, 'width' => $art->width, 'height' => $art->height, ]; }); // ── 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 ─────────────────────────────────────────────────────── $favourites = collect(); if (Schema::hasTable('user_favorites')) { $favIds = DB::table('user_favorites as uf') ->join('artworks as a', 'a.id', '=', 'uf.artwork_id') ->where('uf.user_id', $user->id) ->whereNull('a.deleted_at') ->where('a.is_public', true) ->where('a.is_approved', true) ->orderByDesc('uf.created_at') ->limit(12) ->pluck('a.id'); if ($favIds->isNotEmpty()) { $indexed = Artwork::with('user:id,name,username') ->whereIn('id', $favIds) ->get() ->keyBy('id'); // Preserve the ordering from the favourites table $favourites = $favIds ->filter(fn ($id) => $indexed->has($id)) ->map(fn ($id) => $indexed[$id]); } } // ── 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; if (Schema::hasTable('user_followers')) { $followerCount = DB::table('user_followers')->where('user_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(); } } // ── 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', '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_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, ]); } // ── Profile data ───────────────────────────────────────────────────── $profile = $user->profile; // ── Country name (from old country_list table if available) ────────── $countryName = null; if ($profile?->country_code) { if (Schema::hasTable('country_list')) { $countryName = DB::table('country_list') ->where('country_code', $profile->country_code) ->value('country_name'); } $countryName = $countryName ?? strtoupper((string) $profile->country_code); } // ── Hero background artwork ───────────────────────────────────────── $heroBgUrl = Artwork::public() ->published() ->where('user_id', $user->id) ->whereNotNull('hash') ->whereNotNull('thumb_ext') ->inRandomOrder() ->limit(1) ->first()?->thumbUrl('lg'); // ── Increment profile views (async-safe, ignore errors) ────────────── if (! $isOwner) { try { DB::table('user_statistics') ->updateOrInsert( ['user_id' => $user->id], ['profile_views' => DB::raw('COALESCE(profile_views, 0) + 1'), 'updated_at' => now()] ); } catch (\Throwable) {} } return response()->view('legacy::profile', [ 'user' => $user, 'profile' => $profile, 'artworks' => $artworks, 'featuredArtworks' => $featuredArtworks, 'favourites' => $favourites, 'stats' => $stats, 'socialLinks' => $socialLinks, 'followerCount' => $followerCount, 'recentFollowers' => $recentFollowers, 'viewerIsFollowing' => $viewerIsFollowing, 'heroBgUrl' => $heroBgUrl, 'profileComments' => $profileComments, 'countryName' => $countryName, 'isOwner' => $isOwner, 'page_title' => 'Profile: ' . ($user->username ?? $user->name ?? ''), 'page_canonical' => url('/@' . strtolower((string) ($user->username ?? ''))), 'page_meta_description' => 'View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.', ]); } }