Files
SkinbaseNova/app/Http/Controllers/User/ProfileController.php
2026-03-20 21:17:26 +01:00

1199 lines
50 KiB
PHP

<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\ProfileUpdateRequest;
use App\Http\Requests\Settings\RequestEmailChangeRequest;
use App\Http\Requests\Settings\UpdateAccountSectionRequest;
use App\Http\Requests\Settings\UpdateNotificationsSectionRequest;
use App\Http\Requests\Settings\UpdatePersonalSectionRequest;
use App\Http\Requests\Settings\UpdateProfileSectionRequest;
use App\Http\Requests\Settings\UpdateSecurityPasswordRequest;
use App\Http\Requests\Settings\VerifyEmailChangeRequest;
use App\Mail\EmailChangedSecurityAlertMail;
use App\Mail\EmailChangeVerificationCodeMail;
use App\Models\Artwork;
use App\Models\Country;
use App\Models\ProfileComment;
use App\Models\Story;
use App\Models\User;
use Carbon\CarbonInterface;
use App\Services\Security\CaptchaVerifier;
use App\Services\AvatarService;
use App\Services\ArtworkService;
use App\Services\FollowService;
use App\Services\AchievementService;
use App\Services\LeaderboardService;
use App\Services\Countries\CountryCatalogService;
use App\Services\ThumbnailPresenter;
use App\Services\ThumbnailService;
use App\Services\XPService;
use App\Services\UsernameApprovalService;
use App\Services\UserStatsService;
use App\Support\AvatarUrl;
use App\Support\CoverUrl;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Schema;
use Illuminate\View\View;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\Rules\Password as PasswordRule;
use Inertia\Inertia;
class ProfileController extends Controller
{
public function __construct(
private readonly ArtworkService $artworkService,
private readonly UsernameApprovalService $usernameApprovalService,
private readonly FollowService $followService,
private readonly UserStatsService $userStats,
private readonly CaptchaVerifier $captchaVerifier,
private readonly XPService $xp,
private readonly AchievementService $achievements,
private readonly LeaderboardService $leaderboards,
private readonly CountryCatalogService $countryCatalog,
)
{
}
public function showByUsername(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.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->renderProfilePage($request, $user);
}
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 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)
{
$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;
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',
'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);
$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(),
]);
// ── 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');
$achievementSummary = $this->achievements->summary((int) $user->id);
$leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id);
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' => $stats,
'socialLinks' => $socialLinks,
'followerCount' => $followerCount,
'recentFollowers' => $recentFollowers->values(),
'viewerIsFollowing' => $viewerIsFollowing,
'heroBgUrl' => $heroBgUrl,
'profileComments' => $profileComments->values(),
'creatorStories' => $creatorStories->values(),
'achievements' => $achievementSummary,
'leaderboardRank' => $leaderboardRank,
'countryName' => $countryName,
'isOwner' => $isOwner,
'auth' => $authData,
'profileUrl' => $canonical,
'galleryUrl' => $galleryUrl,
])->withViewData([
'page_title' => $galleryOnly
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase'),
'page_canonical' => $galleryOnly ? $galleryUrl : $canonical,
'page_meta_description' => $galleryOnly
? ('Browse the public gallery of ' . ($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 resolveFavouriteTable(): ?string
{
foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) {
if (Schema::hasTable($table)) {
return $table;
}
}
return null;
}
/**
* @return array<string, mixed>
*/
private function mapArtworkCardPayload(Artwork $art): array
{
$present = ThumbnailPresenter::present($art, 'md');
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'),
'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;
}
}