From 9d67b54b2c33b506d3e9583d07c59db333b3519e Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Sat, 17 Aug 2024 17:37:54 -0400 Subject: [PATCH] refactor: migrate settings page back-end code to controllers (#2596) --- app/Actions/ClearAccountDataAction.php | 3 +- .../Concerns/ActsAsCommunityMember.php | 14 + .../Controllers/UserCommentController.php | 14 +- .../Controllers/UserSettingsController.php | 121 ++++++ app/Community/Data/UpdateEmailData.php | 23 ++ app/Community/Data/UpdatePasswordData.php | 23 ++ app/Community/Data/UpdateProfileData.php | 37 ++ app/Community/Data/UpdateWebsitePrefsData.php | 30 ++ .../Requests/ProfileSettingsRequest.php | 2 +- .../Requests/ResetConnectApiKeyRequest.php | 24 ++ .../Requests/ResetWebApiKeyRequest.php | 24 ++ app/Community/Requests/UpdateEmailRequest.php | 23 ++ .../Requests/UpdatePasswordRequest.php | 42 +++ .../Requests/UpdateProfileRequest.php | 36 ++ .../Requests/UpdateWebsitePrefsRequest.php | 23 ++ app/Community/RouteServiceProvider.php | 30 +- app/Helpers/database/user-auth.php | 23 +- app/Helpers/database/user-email-verify.php | 78 ++-- app/Helpers/util/mail.php | 5 +- app/Http/Controllers/SettingsController.php | 55 --- app/Http/Controllers/UserController.php | 72 ++++ app/Models/Comment.php | 8 + app/Models/EmailConfirmation.php | 39 ++ app/Models/User.php | 1 + .../PlayerAchievementController.php | 11 +- .../Controllers/PlayerGameController.php | 66 +++- .../PlayerResettableGameAchievementData.php | 20 + .../Data/PlayerResettableGameData.php | 21 ++ .../Requests/ResetPlayerProgressRequest.php | 24 ++ app/Platform/RouteServiceProvider.php | 7 + app/Policies/UserPolicy.php | 32 +- app/Providers/RouteServiceProvider.php | 17 +- database/factories/CommentFactory.php | 31 ++ .../2012_10_03_133633_create_base_tables.php | 1 + ...000000_update_emailconfirmations_table.php | 34 ++ public/request/auth/delete-account-cancel.php | 11 - public/request/auth/delete-account.php | 14 - public/request/auth/register.php | 4 +- public/request/auth/reset-api-key.php | 11 - public/request/auth/reset-connect-key.php | 11 - .../request/auth/send-verification-email.php | 2 +- public/request/auth/update-password.php | 27 -- public/request/user-comment/delete-all.php | 21 -- public/request/user-comment/toggle.php | 29 -- public/request/user/list-games.php | 26 -- public/request/user/list-unlocks.php | 30 -- public/request/user/update-avatar.php | 32 -- public/request/user/update-email.php | 27 -- public/request/user/update-motto.php | 33 -- .../views/components/menu/account.blade.php | 2 +- .../views/pages-legacy/controlpanel.blade.php | 347 +++++++++++++----- .../Controllers/UserCommentControllerTest.php | 71 ++++ .../UserSettingsControllerTest.php | 202 ++++++++++ 53 files changed, 1402 insertions(+), 512 deletions(-) create mode 100644 app/Community/Controllers/UserSettingsController.php create mode 100644 app/Community/Data/UpdateEmailData.php create mode 100644 app/Community/Data/UpdatePasswordData.php create mode 100644 app/Community/Data/UpdateProfileData.php create mode 100644 app/Community/Data/UpdateWebsitePrefsData.php rename app/{Http => Community}/Requests/ProfileSettingsRequest.php (97%) create mode 100644 app/Community/Requests/ResetConnectApiKeyRequest.php create mode 100644 app/Community/Requests/ResetWebApiKeyRequest.php create mode 100644 app/Community/Requests/UpdateEmailRequest.php create mode 100644 app/Community/Requests/UpdatePasswordRequest.php create mode 100644 app/Community/Requests/UpdateProfileRequest.php create mode 100644 app/Community/Requests/UpdateWebsitePrefsRequest.php delete mode 100644 app/Http/Controllers/SettingsController.php create mode 100644 app/Models/EmailConfirmation.php create mode 100644 app/Platform/Data/PlayerResettableGameAchievementData.php create mode 100644 app/Platform/Data/PlayerResettableGameData.php create mode 100644 app/Platform/Requests/ResetPlayerProgressRequest.php create mode 100644 database/factories/CommentFactory.php create mode 100644 database/migrations/2024_08_07_000000_update_emailconfirmations_table.php delete mode 100644 public/request/auth/delete-account-cancel.php delete mode 100644 public/request/auth/delete-account.php delete mode 100644 public/request/auth/reset-api-key.php delete mode 100644 public/request/auth/reset-connect-key.php delete mode 100644 public/request/auth/update-password.php delete mode 100644 public/request/user-comment/delete-all.php delete mode 100644 public/request/user-comment/toggle.php delete mode 100644 public/request/user/list-games.php delete mode 100644 public/request/user/list-unlocks.php delete mode 100644 public/request/user/update-avatar.php delete mode 100644 public/request/user/update-email.php delete mode 100644 public/request/user/update-motto.php create mode 100644 tests/Feature/Community/Controllers/UserCommentControllerTest.php create mode 100644 tests/Feature/Community/Controllers/UserSettingsControllerTest.php diff --git a/app/Actions/ClearAccountDataAction.php b/app/Actions/ClearAccountDataAction.php index 70aa34a3fc..8a9c8b6cd8 100644 --- a/app/Actions/ClearAccountDataAction.php +++ b/app/Actions/ClearAccountDataAction.php @@ -27,8 +27,7 @@ public function execute(User $user): void ); // TODO $user->activities()->delete(); - // TODO $user->emailConfirmations()->delete(); - DB::statement('DELETE FROM EmailConfirmations WHERE User = :username', ['username' => $user->User]); + $user->emailConfirmations()->delete(); $user->relatedUsers()->detach(); $user->inverseRelatedUsers()->detach(); // TODO $user->ratings()->delete(); diff --git a/app/Community/Concerns/ActsAsCommunityMember.php b/app/Community/Concerns/ActsAsCommunityMember.php index eff8cf184d..5879026e58 100644 --- a/app/Community/Concerns/ActsAsCommunityMember.php +++ b/app/Community/Concerns/ActsAsCommunityMember.php @@ -5,6 +5,7 @@ namespace App\Community\Concerns; use App\Community\Enums\UserRelationship; +use App\Models\EmailConfirmation; use App\Models\ForumTopicComment; use App\Models\MessageThreadParticipant; use App\Models\Subscription; @@ -103,6 +104,11 @@ public function isFriendsWith(User $user): bool return $this->isFollowing($user) && $user->isFollowing($this); } + public function isEmailVerified(): bool + { + return !empty($this->email_verified_at); + } + public function isForumVerified(): bool { return !empty($this->forum_verified_at); @@ -161,6 +167,14 @@ public function comments(): MorphMany return $this->morphMany(UserComment::class, 'commentable')->with('user'); } + /** + * @return HasMany + */ + public function emailConfirmations(): HasMany + { + return $this->hasMany(EmailConfirmation::class, 'user_id', 'ID'); + } + /** * @return HasMany */ diff --git a/app/Community/Controllers/UserCommentController.php b/app/Community/Controllers/UserCommentController.php index ac739c146e..b1ea39b8f9 100644 --- a/app/Community/Controllers/UserCommentController.php +++ b/app/Community/Controllers/UserCommentController.php @@ -6,12 +6,15 @@ use App\Community\Actions\AddCommentAction; use App\Community\Actions\GetUrlToCommentDestinationAction; +use App\Community\Enums\ArticleType; use App\Community\Requests\CommentRequest; use App\Models\Comment; use App\Models\User; use App\Models\UserComment; use Illuminate\Contracts\View\View; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; class UserCommentController extends CommentController { @@ -95,12 +98,15 @@ protected function destroy(UserComment $comment): RedirectResponse ->with('success', $this->resourceActionSuccessMessage('user.comment', 'delete')); } - public function destroyAll(User $user): RedirectResponse + public function destroyAll(Request $request, int $targetUserId): JsonResponse { - $this->authorize('deleteComments', $user); + $targetUser = User::findOrFail($targetUserId); + $this->authorize('clearUserWall', $targetUser); - $user->comments()->delete(); + Comment::where('ArticleType', ArticleType::User) + ->where('ArticleID', $targetUser->id) + ->delete(); - return back()->with('success', $this->resourceActionSuccessMessage('user.comment', 'delete')); + return response()->json(['success' => true]); } } diff --git a/app/Community/Controllers/UserSettingsController.php b/app/Community/Controllers/UserSettingsController.php new file mode 100644 index 0000000000..9c76a317b9 --- /dev/null +++ b/app/Community/Controllers/UserSettingsController.php @@ -0,0 +1,121 @@ +authorize('updateSettings', $section); + + if (!view()->exists("settings.$section")) { + abort(404, 'Not found'); + } + + return view("settings.$section"); + } + + public function updatePassword(UpdatePasswordRequest $request): JsonResponse + { + $data = UpdatePasswordData::fromRequest($request); + + /** @var User $user */ + $user = $request->user(); + + changePassword($user->username, $data->newPassword); + generateAppToken($user->username, $tokenInOut); + + return response()->json(['success' => true]); + } + + public function updateEmail(UpdateEmailRequest $request): JsonResponse + { + $data = UpdateEmailData::fromRequest($request); + + /** @var User $user */ + $user = $request->user(); + + // The user will need to reconfirm their email address. + $user->EmailAddress = $data->newEmail; + $user->setAttribute('Permissions', Permissions::Unregistered); + $user->email_verified_at = null; + $user->save(); + + // TODO move this to an action, use Fortify, do something else. + // sendValidationEmail cannot be invoked while under test. + if (app()->environment() !== 'testing') { + sendValidationEmail($user, $data->newEmail); + } + + addArticleComment( + 'Server', + ArticleType::UserModeration, + $user->id, + "{$user->username} changed their email address" + ); + + return response()->json(['success' => true]); + } + + public function updateProfile(UpdateProfileRequest $request): JsonResponse + { + $data = UpdateProfileData::fromRequest($request); + + /** @var User $user */ + $user = $request->user(); + + $user->update($data->toArray()); + + return response()->json(['success' => true]); + } + + // TODO migrate to $user->preferences blob + public function updatePreferences(UpdateWebsitePrefsRequest $request): JsonResponse + { + $data = UpdateWebsitePrefsData::fromRequest($request); + + /** @var User $user */ + $user = $request->user(); + + $user->update($data->toArray()); + + return response()->json(['success' => true]); + } + + public function resetWebApiKey(ResetWebApiKeyRequest $request): JsonResponse + { + $newKey = generateAPIKey($request->user()->username); + + return response()->json(['newKey' => $newKey]); + } + + public function resetConnectApiKey(ResetConnectApiKeyRequest $request): JsonResponse + { + generateAppToken($request->user()->username, $newToken); + + return response()->json(['success' => true]); + } +} diff --git a/app/Community/Data/UpdateEmailData.php b/app/Community/Data/UpdateEmailData.php new file mode 100644 index 0000000000..520a42cc5a --- /dev/null +++ b/app/Community/Data/UpdateEmailData.php @@ -0,0 +1,23 @@ +newEmail, + ); + } +} diff --git a/app/Community/Data/UpdatePasswordData.php b/app/Community/Data/UpdatePasswordData.php new file mode 100644 index 0000000000..d60bcb35e7 --- /dev/null +++ b/app/Community/Data/UpdatePasswordData.php @@ -0,0 +1,23 @@ +newPassword, + ); + } +} diff --git a/app/Community/Data/UpdateProfileData.php b/app/Community/Data/UpdateProfileData.php new file mode 100644 index 0000000000..916946be74 --- /dev/null +++ b/app/Community/Data/UpdateProfileData.php @@ -0,0 +1,37 @@ +user(); + + return new self( + motto: $request->motto ?? $user->Motto, + userWallActive: $request->userWallActive ?? $user->UserWallActive, + ); + } + + public function toArray(): array + { + return [ + 'Motto' => $this->motto, + 'UserWallActive' => $this->userWallActive, + ]; + } +} diff --git a/app/Community/Data/UpdateWebsitePrefsData.php b/app/Community/Data/UpdateWebsitePrefsData.php new file mode 100644 index 0000000000..ebe15886e3 --- /dev/null +++ b/app/Community/Data/UpdateWebsitePrefsData.php @@ -0,0 +1,30 @@ +websitePrefs, + ); + } + + public function toArray(): array + { + return [ + 'websitePrefs' => $this->websitePrefs, + ]; + } +} diff --git a/app/Http/Requests/ProfileSettingsRequest.php b/app/Community/Requests/ProfileSettingsRequest.php similarity index 97% rename from app/Http/Requests/ProfileSettingsRequest.php rename to app/Community/Requests/ProfileSettingsRequest.php index ab9d1c06e9..de65925d52 100644 --- a/app/Http/Requests/ProfileSettingsRequest.php +++ b/app/Community/Requests/ProfileSettingsRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Http\Requests; +namespace App\Community\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; diff --git a/app/Community/Requests/ResetConnectApiKeyRequest.php b/app/Community/Requests/ResetConnectApiKeyRequest.php new file mode 100644 index 0000000000..acdcb83767 --- /dev/null +++ b/app/Community/Requests/ResetConnectApiKeyRequest.php @@ -0,0 +1,24 @@ +user(); + + return $user->can('manipulateApiKeys', $user); + } + + public function rules(): array + { + return []; + } +} diff --git a/app/Community/Requests/ResetWebApiKeyRequest.php b/app/Community/Requests/ResetWebApiKeyRequest.php new file mode 100644 index 0000000000..25f904677d --- /dev/null +++ b/app/Community/Requests/ResetWebApiKeyRequest.php @@ -0,0 +1,24 @@ +user(); + + return $user->can('manipulateApiKeys', $user); + } + + public function rules(): array + { + return []; + } +} diff --git a/app/Community/Requests/UpdateEmailRequest.php b/app/Community/Requests/UpdateEmailRequest.php new file mode 100644 index 0000000000..07bb2c0056 --- /dev/null +++ b/app/Community/Requests/UpdateEmailRequest.php @@ -0,0 +1,23 @@ + 'required|email', + ]; + } +} diff --git a/app/Community/Requests/UpdatePasswordRequest.php b/app/Community/Requests/UpdatePasswordRequest.php new file mode 100644 index 0000000000..338d5d274d --- /dev/null +++ b/app/Community/Requests/UpdatePasswordRequest.php @@ -0,0 +1,42 @@ + ['required', function ($attribute, $value, $fail) { + $user = $this->user(); + if (!Hash::check($value, $user->Password)) { + $fail(__('legacy.error.credentials')); + } + }], + 'newPassword' => 'required|min:8', + ]; + } + + public function withValidator(Validator $validator): void + { + $validator->after(function ($validator) { + $user = $this->user(); + $newPassword = $this->input('newPassword'); + + if ($newPassword === $user->username) { + $validator->errors()->add('newPassword', 'Your password must be different from your username.'); + } + }); + } +} diff --git a/app/Community/Requests/UpdateProfileRequest.php b/app/Community/Requests/UpdateProfileRequest.php new file mode 100644 index 0000000000..e20575e65d --- /dev/null +++ b/app/Community/Requests/UpdateProfileRequest.php @@ -0,0 +1,36 @@ +user(); + + if ($user->isBanned()) { + return false; + } + + $isMottoBeingUpdated = $this->has('motto') && $this->input('motto') !== $user->Motto; + if ($isMottoBeingUpdated && !$user->can('updateMotto', $user)) { + return false; + } + + return true; + } + + public function rules(): array + { + return [ + 'motto' => 'nullable|string|max:50', + 'userWallActive' => 'nullable|boolean', + ]; + } +} diff --git a/app/Community/Requests/UpdateWebsitePrefsRequest.php b/app/Community/Requests/UpdateWebsitePrefsRequest.php new file mode 100644 index 0000000000..f4f36d6de1 --- /dev/null +++ b/app/Community/Requests/UpdateWebsitePrefsRequest.php @@ -0,0 +1,23 @@ + 'required|integer', + ]; + } +} diff --git a/app/Community/RouteServiceProvider.php b/app/Community/RouteServiceProvider.php index bf4c899ade..f4c3cf986b 100755 --- a/app/Community/RouteServiceProvider.php +++ b/app/Community/RouteServiceProvider.php @@ -8,6 +8,8 @@ use App\Community\Controllers\ForumTopicController; use App\Community\Controllers\MessageController; use App\Community\Controllers\MessageThreadController; +use App\Community\Controllers\UserCommentController; +use App\Community\Controllers\UserSettingsController; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Route; @@ -153,9 +155,11 @@ protected function mapWebRoutes(): void * protected routes, need an authenticated user with a verified email address * permissions are checked in controllers individually by authorizing abilities in the respective controller actions */ - // Route::group([ - // 'middleware' => ['auth', 'verified'], - // ], function () { + Route::group([ + 'middleware' => ['auth', 'verified'], + ], function () { + Route::delete('user/{user}/comments', [UserCommentController::class, 'destroyAll'])->name('user.comment.destroyAll'); + // /* // * commentables // * nested auth comments routes -> no conflicts with id/slug in route @@ -221,8 +225,6 @@ protected function mapWebRoutes(): void // ]) // ->shallow(); // }); - // // additional "delete all" route - // Route::delete('user/{user}/comments', [UserCommentController::class, 'destroyAll'])->name('user.comment.destroyAll'); // /* // * "My" friends @@ -233,6 +235,8 @@ protected function mapWebRoutes(): void // // Route::get('history', [PlayerHistoryController::class, 'index'])->name('history.index'); + }); + /* * messages */ @@ -242,6 +246,22 @@ protected function mapWebRoutes(): void Route::resource('message', MessageController::class)->only(['store']); Route::resource('message-thread', MessageThreadController::class)->parameter('message-thread', 'messageThread')->only(['destroy']); }); + + /* + * user settings + */ + Route::group([ + 'middleware' => ['auth'], + 'prefix' => 'settings', + ], function () { + Route::put('profile', [UserSettingsController::class, 'updateProfile'])->name('settings.profile.update'); + Route::put('preferences', [UserSettingsController::class, 'updatePreferences'])->name('settings.preferences.update'); + Route::put('password', [UserSettingsController::class, 'updatePassword'])->name('settings.password.update'); + Route::put('email', [UserSettingsController::class, 'updateEmail'])->name('settings.email.update'); + + Route::delete('keys/web', [UserSettingsController::class, 'resetWebApiKey'])->name('settings.keys.web.destroy'); + Route::delete('keys/connect', [UserSettingsController::class, 'resetConnectApiKey'])->name('settings.keys.connect.destroy'); + }); }); } } diff --git a/app/Helpers/database/user-auth.php b/app/Helpers/database/user-auth.php index d611bea304..dcbf0a186a 100644 --- a/app/Helpers/database/user-auth.php +++ b/app/Helpers/database/user-auth.php @@ -257,28 +257,17 @@ function newAppToken(): string * TODO replace with passport personal token */ -function generateAPIKey(string $user): string +function generateAPIKey(string $username): string { - sanitize_sql_inputs($user); - - if (!getAccountDetails($user, $userData)) { - return ""; - } - - if ($userData['Permissions'] < Permissions::Registered) { - return ""; + $user = User::firstWhere('User', $username); + if (!$user || !$user->isEmailVerified()) { + return ''; } $newKey = Str::random(32); - $query = "UPDATE UserAccounts AS ua - SET ua.APIKey='$newKey', Updated=NOW() - WHERE ua.User = '$user'"; - - $dbResult = s_mysql_query($query); - if (!$dbResult) { - return ""; - } + $user->APIKey = $newKey; + $user->save(); return $newKey; } diff --git a/app/Helpers/database/user-email-verify.php b/app/Helpers/database/user-email-verify.php index ca72c5e31e..99044d499d 100644 --- a/app/Helpers/database/user-email-verify.php +++ b/app/Helpers/database/user-email-verify.php @@ -1,28 +1,26 @@ $user->username, + 'user_id' => $user->id, + 'EmailCookie' => $emailCookie, + 'Expires' => $expiry, + ]); // Clear permissions til they validate their email. - $userModel = User::firstWhere('User', $user); - if (!$userModel->isBanned) { - SetAccountPermissionsJSON('Server', Permissions::Moderator, $user, Permissions::Unregistered); + if (!$user->isBanned) { + SetAccountPermissionsJSON('Server', Permissions::Moderator, $user->username, Permissions::Unregistered); } return $emailCookie; @@ -33,43 +31,39 @@ function generateEmailVerificationToken(string $user): ?string */ function validateEmailVerificationToken(string $emailCookie, ?string &$user): bool { - sanitize_sql_inputs($emailCookie); - - $query = "SELECT * FROM EmailConfirmations WHERE EmailCookie='$emailCookie'"; - $dbResult = s_mysql_query($query); - - if (!$dbResult) { - log_sql_fail(); + $emailConfirmation = EmailConfirmation::firstWhere('EmailCookie', $emailCookie); + if (!$emailConfirmation) { return false; } - if (mysqli_num_rows($dbResult) == 1) { - $data = mysqli_fetch_assoc($dbResult); - $user = $data['User']; + $user = User::find($emailConfirmation->user_id); + // TODO delete after dropping User from EmailConfirmations + if (!$user) { + $user = User::firstWhere('User', $emailConfirmation->User); + } + // ENDTODO delete after dropping User from EmailConfirmations - if (getUserPermissions($user) != Permissions::Unregistered) { - return false; - } + if (!$user) { + return false; + } - $query = "DELETE FROM EmailConfirmations WHERE User='$user'"; - $dbResult = s_mysql_query($query); - if (!$dbResult) { - log_sql_fail(); + if ((int) $user->getAttribute('Permissions') !== Permissions::Unregistered) { + return false; + } - return false; - } + $emailConfirmation->delete(); - $response = SetAccountPermissionsJSON('Server', Permissions::Moderator, $user, Permissions::Registered); - if ($response['Success']) { - static_addnewregistereduser($user); - generateAPIKey($user); + $response = SetAccountPermissionsJSON('Server', Permissions::Moderator, $user->username, Permissions::Registered); + if ($response['Success']) { + static_addnewregistereduser($user->username); + generateAPIKey($user->username); - User::where('User', $user)->update(['email_verified_at' => now()]); + $user->email_verified_at = Carbon::now(); + $user->save(); - // SUCCESS: validated email address for $user - return true; - } + // SUCCESS: validated email address for $user + return true; } return false; @@ -77,5 +71,7 @@ function validateEmailVerificationToken(string $emailCookie, ?string &$user): bo function deleteExpiredEmailVerificationTokens(): bool { - return (bool) s_mysql_query("DELETE FROM EmailConfirmations WHERE Expires <= DATE(NOW()) ORDER BY Expires DESC"); + EmailConfirmation::where('Expires', '<=', Carbon::today())->delete(); + + return true; } diff --git a/app/Helpers/util/mail.php b/app/Helpers/util/mail.php index 8ad8f8607f..c47bb809f0 100644 --- a/app/Helpers/util/mail.php +++ b/app/Helpers/util/mail.php @@ -2,6 +2,7 @@ use App\Community\Enums\ArticleType; use App\Enums\Permissions; +use App\Models\User; use Aws\CommandPool; use Illuminate\Contracts\Mail\Mailer as MailerContract; use Illuminate\Mail\Mailer; @@ -148,14 +149,14 @@ function mail_ses(string $to, string $subject = '(No subject)', string $message } } -function sendValidationEmail(string $user, string $email): bool +function sendValidationEmail(User $user, string $email): bool { // This generates and stores (and returns) a new email validation string in the DB. $strValidation = generateEmailVerificationToken($user); $strEmailLink = config('app.url') . "/validateEmail.php?v=$strValidation"; // $subject = "RetroAchievements.org - Confirm Email: $user"; - $subject = "Welcome to RetroAchievements.org, $user"; + $subject = "Welcome to RetroAchievements.org, {$user->display_name}"; $msg = "You or someone using your email address has attempted to sign up for an account at RetroAchievements.org
" . "
" . diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php deleted file mode 100644 index 6929a631db..0000000000 --- a/app/Http/Controllers/SettingsController.php +++ /dev/null @@ -1,55 +0,0 @@ -authorize('updateSettings', $section); - - if (!view()->exists("settings.$section")) { - abort(404, 'Not found'); - } - - return view("settings.$section"); - } - - /** - * Update the specified resource in storage. - */ - public function updateProfile( - ProfileSettingsRequest $request, - UpdateAvatarAction $updateAvatarAction - ): RedirectResponse { - $this->authorize('updateProfileSettings', $request->user()); - - /** - * settings are always processed in the current user's context - */ - /** @var User $user */ - $user = $request->user(); - - $updateAvatarAction->execute($user, $request); - $data = $request->validated(); - // $data['wall_active'] = $data['wall_active'] ?? false; - $user->fill($data)->save(); - - // dd($data); - - return back()->with('success', $this->resourceActionSuccessMessage('setting.profile', 'update', null, 2)); - } -} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 19d7c9b48e..fbfa3ee7a2 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -7,10 +7,15 @@ use App\Http\Controller; use App\Models\PlayerGame; use App\Models\User; +use App\Platform\Actions\RequestAccountDeletion; +use Exception; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\View\View; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Routing\Redirector; +use Illuminate\Support\Facades\Log; use Jenssegers\Optimus\Optimus; class UserController extends Controller @@ -79,4 +84,71 @@ public function permalink(Optimus $optimus, int $hashId): Redirector|Application return redirect(route('user.show', $user)); } + + public function uploadAvatar(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + $this->authorize('updateAvatar', $user); + + try { + UploadAvatar($user->username, $request->imageData); + + return response()->json(['success' => true]); + } catch (Exception $exception) { + $error = $exception->getMessage(); + + // Handle specific error messages + if ($error == 'Invalid file type' || $error == 'File too large') { + return response()->json(['message' => $error], 400); + } + + if (preg_match('/(not a .* file)/i', $exception->getMessage(), $match)) { + return response()->json(['message' => ucfirst($match[0])], 400); + } + + // Log unexpected errors and return a 500 error + Log::error($exception->getMessage()); + + return response()->json(['message' => __('legacy.error.server')], 500); + } + } + + public function deleteAvatar(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + $this->authorize('updateAvatar', $user); + + removeAvatar($user->username); + + return response()->json(['success' => true]); + } + + public function requestAccountDeletion(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + $this->authorize('update', $user); + + (new RequestAccountDeletion())->execute($user); + + return response()->json(['success' => true]); + } + + public function cancelAccountDeletion(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + /** @var User $user */ + $this->authorize('update', $user); + + cancelDeleteRequest($user->username); + + return response()->json(['success' => true]); + } } diff --git a/app/Models/Comment.php b/app/Models/Comment.php index 52f58262ae..9088fcb259 100644 --- a/app/Models/Comment.php +++ b/app/Models/Comment.php @@ -5,7 +5,9 @@ namespace App\Models; use App\Support\Database\Eloquent\BaseModel; +use Database\Factories\CommentFactory; use Exception; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -16,6 +18,7 @@ class Comment extends BaseModel { use Searchable; use SoftDeletes; + use HasFactory; // TODO rename Comment table to comments // TODO rename ID column id @@ -35,6 +38,11 @@ class Comment extends BaseModel 'Payload', ]; + protected static function newFactory(): CommentFactory + { + return CommentFactory::new(); + } + // == search public function toSearchableArray(): array diff --git a/app/Models/EmailConfirmation.php b/app/Models/EmailConfirmation.php new file mode 100644 index 0000000000..84a811e4f5 --- /dev/null +++ b/app/Models/EmailConfirmation.php @@ -0,0 +1,39 @@ + + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id', 'ID'); + } + + // == scopes +} diff --git a/app/Models/User.php b/app/Models/User.php index 8e46e2202d..587d02f948 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -158,6 +158,7 @@ class User extends Authenticatable implements CommunityMember, Developer, HasCom 'Untracked', 'User', // fillable for registration 'UserWallActive', + 'websitePrefs', ]; protected $visible = [ diff --git a/app/Platform/Controllers/PlayerAchievementController.php b/app/Platform/Controllers/PlayerAchievementController.php index 9301653c42..cb9db2f115 100644 --- a/app/Platform/Controllers/PlayerAchievementController.php +++ b/app/Platform/Controllers/PlayerAchievementController.php @@ -5,8 +5,11 @@ namespace App\Platform\Controllers; use App\Http\Controller; +use App\Models\Achievement; use App\Models\User; +use App\Platform\Actions\ResetPlayerProgress; use Illuminate\Contracts\View\View; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class PlayerAchievementController extends Controller @@ -37,7 +40,13 @@ public function update(Request $request, User $user): void { } - public function destroy(User $user): void + public function destroy(Request $request, Achievement $achievement): JsonResponse { + /** @var User $user */ + $user = $request->user(); + + (new ResetPlayerProgress())->execute($user, achievementID: $achievement->id); + + return response()->json(['message' => __('legacy.success.reset')]); } } diff --git a/app/Platform/Controllers/PlayerGameController.php b/app/Platform/Controllers/PlayerGameController.php index 5d04764771..81ef9caedb 100644 --- a/app/Platform/Controllers/PlayerGameController.php +++ b/app/Platform/Controllers/PlayerGameController.php @@ -9,7 +9,11 @@ use App\Models\PlayerGame; use App\Models\System; use App\Models\User; +use App\Platform\Actions\ResetPlayerProgress; +use App\Platform\Data\PlayerResettableGameAchievementData; +use App\Platform\Data\PlayerResettableGameData; use Illuminate\Contracts\View\View; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class PlayerGameController extends Controller @@ -85,16 +89,62 @@ public function update(Request $request, User $user, Game $game): void $this->authorize('update', [PlayerGame::class, $playerGame]); } - public function destroy(User $user, Game $game): void + public function destroy(Request $request, Game $game): JsonResponse { - $playerGame = $user->playerGames() - ->where('game_id', $game->id) - ->firstOrFail(); + /** @var User $user */ + $user = $request->user(); - $this->authorize('delete', [PlayerGame::class, $playerGame]); + (new ResetPlayerProgress())->execute($user, gameID: $game->id); + + return response()->json(['message' => __('legacy.success.reset')]); + } - /* - * TODO: detach - */ + public function resettableGames(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + $resettableGames = $user + ->games() + ->with('system') + ->where('player_games.achievements_unlocked', '>', 0) + ->whereNotIn('ConsoleID', System::getNonGameSystems()) + ->orderBy('Title') + ->select(['GameData.ID', 'Title', 'ConsoleID', 'achievements_published', 'player_games.achievements_unlocked']) + ->get() + ->map(function ($game) { + return new PlayerResettableGameData( + id: $game->id, + title: $game->title, + consoleName: $game->system->name, + numAwarded: $game->achievements_unlocked, + numPossible: $game->achievements_published + ); + }); + + return response()->json(['results' => $resettableGames]); + } + + public function resettableGameAchievements(Request $request, Game $game): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + $resettableGameAchievements = $user + ->achievements() + ->where('GameID', $game->id) + ->withPivot(['unlocked_at', 'unlocked_hardcore_at']) + ->orderBy('Title') + ->get() + ->map(function ($unlockedAchievement) { + return new PlayerResettableGameAchievementData( + id: $unlockedAchievement->id, + title: $unlockedAchievement->title, + points: $unlockedAchievement->points, + isHardcore: $unlockedAchievement->pivot->unlocked_hardcore_at ? true : false, + ); + }); + + return response()->json(['results' => $resettableGameAchievements]); } } diff --git a/app/Platform/Data/PlayerResettableGameAchievementData.php b/app/Platform/Data/PlayerResettableGameAchievementData.php new file mode 100644 index 0000000000..2ec9f60da8 --- /dev/null +++ b/app/Platform/Data/PlayerResettableGameAchievementData.php @@ -0,0 +1,20 @@ + 'required_without:achievementId|integer|exists:GameData,ID', + 'achievementId' => 'required_without:gameId|integer|exists:Achievements,ID', + ]; + } +} diff --git a/app/Platform/RouteServiceProvider.php b/app/Platform/RouteServiceProvider.php index ad6ccabd88..d6ebb6313a 100755 --- a/app/Platform/RouteServiceProvider.php +++ b/app/Platform/RouteServiceProvider.php @@ -7,6 +7,7 @@ use App\Models\GameHash; use App\Platform\Controllers\AchievementController; use App\Platform\Controllers\GameHashController; +use App\Platform\Controllers\PlayerAchievementController; use App\Platform\Controllers\PlayerGameController; use App\Platform\Controllers\SystemController; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; @@ -87,6 +88,12 @@ protected function mapWebRoutes(): void 'middleware' => ['auth'], // TODO: 'verified' ], function () { Route::resource('game-hash', GameHashController::class)->parameters(['game-hash' => 'gameHash'])->only(['update', 'destroy']); + + Route::get('games/resettable', [PlayerGameController::class, 'resettableGames'])->name('player.games.resettable'); + Route::get('game/{game}/achievements/resettable', [PlayerGameController::class, 'resettableGameAchievements'])->name('player.game.achievements.resettable'); + + Route::delete('user/game/{game}', [PlayerGameController::class, 'destroy'])->name('user.game.destroy'); + Route::delete('user/achievement/{achievement}', [PlayerAchievementController::class, 'destroy'])->name('user.achievement.destroy'); }); }); } diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php index d44781ddc0..1b48b22f02 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Policies/UserPolicy.php @@ -211,9 +211,39 @@ public function deleteAvatar(User $user, User $model): bool return $this->requireAdministrativePrivileges($user, $model); } + public function updateMotto(User $user, User $model): bool + { + // users may update their own motto + if ($user->is($model) && $user->isEmailVerified()) { + return true; + } + + return $this->requireAdministrativePrivileges($user, $model); + } + public function deleteMotto(User $user, User $model): bool { - // users may delete their own avatar + // users may delete their own motto + if ($user->is($model)) { + return true; + } + + return $this->requireAdministrativePrivileges($user, $model); + } + + public function manipulateApiKeys(User $user, User $model): bool + { + // users may manipulate their own web and connect api keys + if ($user->is($model) && $user->isEmailVerified()) { + return true; + } + + return $this->requireAdministrativePrivileges($user, $model); + } + + public function clearUserWall(User $user, User $model): bool + { + // users can clear their own profile walls if ($user->is($model)) { return true; } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 0fff7e11aa..f64de16fa2 100755 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -106,18 +106,11 @@ protected function mapWebRoutes(): void ], function () { // Route::get('notifications', [NotificationsController::class, 'index'])->name('notification.index'); - /* - * settings and user attributes - */ - // Route::group(['prefix' => 'settings'], function () { - // Route::get('keys', [SettingsController::class, 'edit'])->middleware('password.confirm'); - // Route::get('{section?}', [SettingsController::class, 'edit'])->name('settings'); - // - // Route::put('profile', [SettingsController::class, 'updateProfile'])->name('settings.profile.update'); - // Route::put('password', [SettingsController::class, 'updatePassword'])->name('settings.password.update'); - // Route::put('email', [SettingsController::class, 'updateEmail'])->name('settings.email.update'); - // Route::put('notifications', [SettingsController::class, 'updateNotificationPreferences'])->name('settings.notifications.update'); - // }); + Route::post('delete-request', [UserController::class, 'requestAccountDeletion'])->name('user.delete-request.store'); + Route::delete('delete-request', [UserController::class, 'cancelAccountDeletion'])->name('user.delete-request.destroy'); + + Route::post('avatar', [UserController::class, 'uploadAvatar'])->name('user.avatar.store'); + Route::delete('avatar', [UserController::class, 'deleteAvatar'])->name('user.avatar.destroy'); }); }); } diff --git a/database/factories/CommentFactory.php b/database/factories/CommentFactory.php new file mode 100644 index 0000000000..7d881f239b --- /dev/null +++ b/database/factories/CommentFactory.php @@ -0,0 +1,31 @@ + + */ +class CommentFactory extends Factory +{ + protected $model = Comment::class; + + public function definition(): array + { + $isEdited = $this->faker->boolean((1 / 12) * 100); // A one-in-twelve chance of being truthy. + + return [ + 'Payload' => $this->faker->paragraph, + 'Submitted' => $this->faker->dateTimeBetween('-1 year', 'now'), + 'Edited' => $isEdited ? $this->faker->dateTimeBetween('now', '+1 year') : null, + 'user_id' => 1, + 'ArticleType' => ArticleType::Achievement, + 'ArticleID' => 1, + ]; + } +} diff --git a/database/migrations/2012_10_03_133633_create_base_tables.php b/database/migrations/2012_10_03_133633_create_base_tables.php index 028ff7cfa7..3704627c81 100644 --- a/database/migrations/2012_10_03_133633_create_base_tables.php +++ b/database/migrations/2012_10_03_133633_create_base_tables.php @@ -185,6 +185,7 @@ public function up(): void if (!Schema::hasTable('EmailConfirmations')) { Schema::create('EmailConfirmations', function (Blueprint $table) { + $table->increments('id'); $table->string('User', 20); $table->string('EmailCookie', 20)->index(); $table->date('Expires'); diff --git a/database/migrations/2024_08_07_000000_update_emailconfirmations_table.php b/database/migrations/2024_08_07_000000_update_emailconfirmations_table.php new file mode 100644 index 0000000000..1c133d8438 --- /dev/null +++ b/database/migrations/2024_08_07_000000_update_emailconfirmations_table.php @@ -0,0 +1,34 @@ +increments('id')->first(); + } + + $table->unsignedBigInteger('user_id')->nullable()->after('User'); + }); + + Schema::table('EmailConfirmations', function (Blueprint $table) { + $table->foreign('user_id')->references('ID')->on('UserAccounts')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::table('EmailConfirmations', function (Blueprint $table) { + $table->dropForeign(['user_id']); + $table->dropColumn('user_id'); + + $table->dropColumn('id'); + }); + } +}; diff --git a/public/request/auth/delete-account-cancel.php b/public/request/auth/delete-account-cancel.php deleted file mode 100644 index 775208ea83..0000000000 --- a/public/request/auth/delete-account-cancel.php +++ /dev/null @@ -1,11 +0,0 @@ -withErrors(__('legacy.error.error')); -} - -if (cancelDeleteRequest($user)) { - return back()->with('success', __('legacy.success.ok')); -} - -return back()->withErrors(__('legacy.error.error')); diff --git a/public/request/auth/delete-account.php b/public/request/auth/delete-account.php deleted file mode 100644 index c29dd4aca3..0000000000 --- a/public/request/auth/delete-account.php +++ /dev/null @@ -1,14 +0,0 @@ -user()) { - return back()->withErrors(__('legacy.error.error')); -} - -$action = new RequestAccountDeletion(); -if ($action->execute(request()->user())) { - return back()->with('success', __('legacy.success.ok')); -} - -return back()->withErrors(__('legacy.error.error')); diff --git a/public/request/auth/register.php b/public/request/auth/register.php index 83867282b8..ddeb7b86c3 100644 --- a/public/request/auth/register.php +++ b/public/request/auth/register.php @@ -1,5 +1,6 @@ with('message', __('legacy.email_validate')); diff --git a/public/request/auth/reset-api-key.php b/public/request/auth/reset-api-key.php deleted file mode 100644 index 8b0c9984bd..0000000000 --- a/public/request/auth/reset-api-key.php +++ /dev/null @@ -1,11 +0,0 @@ -withErrors(__('legacy.error.error')); -} - -generateAPIKey($user); - -return back()->with('success', __('legacy.success.reset')); diff --git a/public/request/auth/reset-connect-key.php b/public/request/auth/reset-connect-key.php deleted file mode 100644 index 3bef4fe5fe..0000000000 --- a/public/request/auth/reset-connect-key.php +++ /dev/null @@ -1,11 +0,0 @@ -withErrors(__('legacy.error.error')); -} - -generateAppToken($user, $token); - -return back()->with('success', __('legacy.success.reset')); diff --git a/public/request/auth/send-verification-email.php b/public/request/auth/send-verification-email.php index cc5c3ead8f..9f672f85ca 100644 --- a/public/request/auth/send-verification-email.php +++ b/public/request/auth/send-verification-email.php @@ -9,7 +9,7 @@ return back()->withErrors(__('legacy.error.permissions')); } -if (sendValidationEmail($user->User, $user->EmailAddress)) { +if (sendValidationEmail($user, $user->EmailAddress)) { return back()->with('message', __('legacy.email_validate')); } diff --git a/public/request/auth/update-password.php b/public/request/auth/update-password.php deleted file mode 100644 index 9cda57df04..0000000000 --- a/public/request/auth/update-password.php +++ /dev/null @@ -1,27 +0,0 @@ -user(); -if (!$user) { - return back()->withErrors(__('legacy.error.account')); -} - -$input = Validator::validate(Arr::wrap(request()->post()), [ - 'password_current' => 'required', - 'password' => 'required|confirmed|min:8|different:username', -]); - -$username = $user->User; - -if (!Hash::check($input['password_current'], $user->Password)) { - return back()->withErrors(__('legacy.error.credentials')); -} - -changePassword($username, $input['password']); -generateAppToken($username, $tokenInOut); - -return back()->with('success', __('legacy.success.password_change')); diff --git a/public/request/user-comment/delete-all.php b/public/request/user-comment/delete-all.php deleted file mode 100644 index c5d818dfa1..0000000000 --- a/public/request/user-comment/delete-all.php +++ /dev/null @@ -1,21 +0,0 @@ -withErrors(__('legacy.error.permissions')); -} - -$db = getMysqliConnection(); -$query = "DELETE FROM Comment - WHERE ArticleType = " . ArticleType::User . " && ArticleID = ( SELECT ua.ID FROM UserAccounts AS ua WHERE ua.User = '$user' )"; - -$dbResult = mysqli_query($db, $query); -if (!$dbResult) { - log_sql_fail(); - - return back()->withErrors(__('legacy.error.error')); -} - -return back()->with('success', __('legacy.success.delete')); diff --git a/public/request/user-comment/toggle.php b/public/request/user-comment/toggle.php deleted file mode 100644 index eb4a5d10f0..0000000000 --- a/public/request/user-comment/toggle.php +++ /dev/null @@ -1,29 +0,0 @@ -withErrors(__('legacy.error.permissions')); -} - -$input = Validator::validate(Arr::wrap(request()->post()), [ - 'active' => 'sometimes|boolean', -]); - -$value = (int) ($input['active'] ?? false); - -$db = getMysqliConnection(); -$query = "UPDATE UserAccounts - SET UserWallActive=$value, Updated=NOW() - WHERE User='$user'"; - -$dbResult = mysqli_query($db, $query); -if (!$dbResult) { - log_sql_fail(); - - return back()->withErrors(__('legacy.error.error')); -} - -return back()->with('success', __('legacy.success.change')); diff --git a/public/request/user/list-games.php b/public/request/user/list-games.php deleted file mode 100644 index 7c46843965..0000000000 --- a/public/request/user/list-games.php +++ /dev/null @@ -1,26 +0,0 @@ -games() - ->with('system') - ->where('player_games.achievements_unlocked', '>', 0) - ->orderBy('Title') - ->select(['GameData.ID', 'Title', 'ConsoleID', 'achievements_published', 'player_games.achievements_unlocked']) - ->get() - ->map(function ($game) { - return [ - 'ID' => $game->ID, - 'GameTitle' => $game->Title, - 'ConsoleName' => $game->system->Name, - 'NumAwarded' => $game->achievements_unlocked, - 'NumPossible' => $game->achievements_published, - ]; - }); - -return response()->json($dataOut); diff --git a/public/request/user/list-unlocks.php b/public/request/user/list-unlocks.php deleted file mode 100644 index 6a1e076773..0000000000 --- a/public/request/user/list-unlocks.php +++ /dev/null @@ -1,30 +0,0 @@ -post()), [ - 'game' => 'required|integer', -]); - -$dataOut = User::firstWhere('User', $user) - ->achievements()->where('GameID', $input['game']) - ->withPivot(['unlocked_at', 'unlocked_hardcore_at']) - ->orderBy('Title') - ->get() - ->map(function ($achievementUnlocked) { - return [ - 'ID' => $achievementUnlocked->ID, - 'Title' => $achievementUnlocked->Title, - 'Points' => $achievementUnlocked->Points, - 'HardcoreMode' => $achievementUnlocked->pivot->unlocked_hardcore_at ? 1 : 0, - ]; - }); - -return response()->json($dataOut); diff --git a/public/request/user/update-avatar.php b/public/request/user/update-avatar.php deleted file mode 100644 index e892f0dc2b..0000000000 --- a/public/request/user/update-avatar.php +++ /dev/null @@ -1,32 +0,0 @@ -json(['message' => __('legacy.error.permissions')], 401); -} - -$userModel = User::firstWhere('User', $user); -if (!$userModel->can('updateAvatar', [User::class])) { - return response()->json(['message' => __('legacy.error.permissions')], 401); -} - -try { - UploadAvatar($user, request()->post('imageData')); -} catch (Exception $exception) { - $error = $exception->getMessage(); - if ($error == 'Invalid file type' || $error == 'File too large') { - return response()->json(['message' => $error], 400); - } - - if (preg_match('/(not a .* file)/i', $exception->getMessage(), $match)) { - return response()->json(['message' => ucfirst($match[0])], 400); - } - - Log::error($exception->getMessage()); - abort(500); -} - -return response()->json(['message' => __('legacy.success.ok')]); diff --git a/public/request/user/update-email.php b/public/request/user/update-email.php deleted file mode 100644 index d684bf754e..0000000000 --- a/public/request/user/update-email.php +++ /dev/null @@ -1,27 +0,0 @@ -withErrors(__('legacy.error.permissions')); -} - -$input = Validator::validate(Arr::wrap(request()->post()), [ - 'email' => 'required|email|confirmed|min:8|different:username', -]); - -$email = $input['email']; - -DB::statement("UPDATE UserAccounts SET EmailAddress='$email', Permissions=" . Permissions::Unregistered . ", email_verified_at = NULL, Updated=NOW() WHERE User='$username'"); - -sendValidationEmail($username, $email); - -addArticleComment('Server', ArticleType::UserModeration, $userDetail['ID'], - $username . ' changed their email address' -); - -return back()->with('success', __('legacy.success.change')); diff --git a/public/request/user/update-motto.php b/public/request/user/update-motto.php deleted file mode 100644 index 6e011e4c87..0000000000 --- a/public/request/user/update-motto.php +++ /dev/null @@ -1,33 +0,0 @@ -post()), [ - 'motto' => 'nullable|string|max:50', -]); - -$newMotto = mb_strcut($input['motto'], 0, 50, "UTF-8"); - -sanitize_sql_inputs($user, $cookie, $newMotto); - -// TODO use model, remove extra sanitization -$query = " - UPDATE UserAccounts - SET Motto='$newMotto', Updated=NOW() - WHERE User='$user'"; - -$db = getMysqliConnection(); -$dbResult = mysqli_query($db, $query); -if (!$dbResult) { - log_sql_fail(); - - return back()->withErrors(__('legacy.error.error')); -} - -return back()->with('success', __('legacy.success.change')); diff --git a/resources/views/components/menu/account.blade.php b/resources/views/components/menu/account.blade.php index 88ff1125e1..5433f0bd01 100644 --- a/resources/views/components/menu/account.blade.php +++ b/resources/views/components/menu/account.blade.php @@ -47,7 +47,7 @@ - + {{ $user->username }} {{ __res('profile', 1) }} diff --git a/resources/views/pages-legacy/controlpanel.blade.php b/resources/views/pages-legacy/controlpanel.blade.php index 24718e8ba6..0d17056866 100644 --- a/resources/views/pages-legacy/controlpanel.blade.php +++ b/resources/views/pages-legacy/controlpanel.blade.php @@ -71,12 +71,17 @@ function DoChangeUserPrefs(targetLoadingIcon = 1) { const loadingIconId = `loadingicon-${targetLoadingIcon}`; ShowLoadingIcon(loadingIconId); - $.post('/request/user/update-preferences.php', { - preferences: newUserPrefs - }) - .done(function () { + + $.ajax({ + url: '{{ route("settings.preferences.update") }}', + type: 'PUT', + contentType: 'application/json; charset=utf-8', + dataType: 'json', + data: JSON.stringify({ websitePrefs: newUserPrefs }), + success: () => { ShowDoneIcon(loadingIconId); - }); + } + }); } function UploadNewAvatar() { @@ -86,7 +91,9 @@ function UploadNewAvatar() { var reader = new FileReader(); reader.onload = function () { ShowLoadingIcon('loadingiconavatar'); - $.post('/request/user/update-avatar.php', { imageData: reader.result }, + + const route = '{{ route("user.avatar.store") }}'; + $.post(route, { imageData: reader.result }, function (data) { ShowDoneIcon('loadingiconavatar'); @@ -102,12 +109,182 @@ function (data) { return false; } -function confirmEmailChange(event) { - = Permissions::Developer): ?> - return confirm('Changing your email address will revoke your privileges and you will need to have them restored by staff.'); - - return true; - +function handleSetMotto(newMotto) { + $.ajax({ + url: '{{ route('settings.profile.update') }}', + type: 'PUT', + data: { motto: newMotto }, + success: () => { + showStatusSuccess('{{ __("legacy.success.change") }}'); + } + }); +} + +function handleSetAllowComments() { + const newValue = document.querySelector('#userwallactive').checked; + + $.ajax({ + url: '{{ route('settings.profile.update') }}', + type: 'PUT', + contentType: 'application/json; charset=utf-8', + dataType: 'json', + data: JSON.stringify({ userWallActive: newValue }), + success: () => { + showStatusSuccess('{{ __("legacy.success.change") }}'); + } + }); +} + +function handleResetWebApiKeyClick() { + if (!confirm('Are you sure you want to reset your web API key?')) { + return; + } + + $.ajax({ + url: '{{ route('settings.keys.web.destroy') }}', + type: 'DELETE', + success: () => { + showStatusSuccess('{{ __("legacy.success.reset") }}'); + + // Temporary, will be removed in the Inertia migration. + setTimeout(() => { + window.location.reload(); + }, 1000) + } + }); +} + +function handleResetConnectApiKeyClick() { + if (!confirm('Are you sure you want to reset your Connect API key?')) { + return; + } + + $.ajax({ + url: '{{ route('settings.keys.connect.destroy') }}', + type: 'DELETE', + success: () => { + showStatusSuccess('{{ __("legacy.success.reset") }}'); + } + }); +} + +function handleDeleteAllUserComments() { + if (!confirm('Are you sure you want to permanently delete all comment on your wall?')) { + return; + } + + $.ajax({ + url: '{{ route('user.comment.destroyAll', $userModel->id) }}', + type: 'DELETE', + success: () => { + showStatusSuccess('{{ __("legacy.success.delete") }}'); + } + }); +} + +function handleChangePasswordSubmit(formValues) { + const { currentPassword, newPassword, confirmPassword } = formValues; + + if (newPassword !== confirmPassword) { + alert("Make sure the New Password and Confirm Password values match."); + + return; + } + + $.ajax({ + url: '{{ route('settings.password.update') }}', + type: 'PUT', + contentType: 'application/json; charset=utf-8', + dataType: 'json', + data: JSON.stringify({ currentPassword, newPassword }), + success: () => { + showStatusSuccess('{{ __("legacy.success.change") }}'); + setTimeout(() => { + const destinationUrl = '{{ route("login") }}'; + window.location.href = destinationUrl; + }, 1000); + } + }); +} + +function handleChangeEmailSubmit(formValues) { + const { newEmail, confirmEmail } = formValues; + + if (newEmail !== confirmEmail) { + alert("Make sure the New Email Address and Confirm Email Address values match."); + + return; + } + + const hasDevPermissions = {{ $permissions >= Permissions::Developer ? 'true' : 'false' }}; + if ( + hasDevPermissions + && !confirm('Changing your email address will revoke your privileges and you will need to have them restored by staff. Are you sure you want to continue?') + ) { + return; + } + + $.ajax({ + url: '{{ route('settings.email.update') }}', + type: 'PUT', + contentType: 'application/json; charset=utf-8', + dataType: 'json', + data: JSON.stringify({ newEmail }), + success: () => { + showStatusSuccess('{{ __("legacy.success.change") }}') + } + }); +} + +function handleRequestAccountDeletion() { + if (!confirm('Are you sure you want to request account deletion?')) { + return; + } + + $.ajax({ + url: '{{ route('user.delete-request.store') }}', + type: 'POST', + success: () => { + showStatusSuccess('{{ __("legacy.success.ok") }}'); + setTimeout(() => { + window.location.reload(); + }, 1000); + } + }); +} + +function handleCancelRequestAccountDeletion() { + if(!confirm('Are you sure you want to cancel your account deletion request?')) { + return; + } + + $.ajax({ + url: '{{ route('user.delete-request.destroy') }}', + type: 'DELETE', + success: () => { + showStatusSuccess('{{ __("legacy.success.ok") }}'); + setTimeout(() => { + window.location.reload(); + }, 1000); + } + }); +} + +function handleRemoveAvatar() { + if (!confirm('Are you sure you want to permanently delete this avatar?')) { + return; + } + + $.ajax({ + url: '{{ route('user.avatar.destroy') }}', + type: 'DELETE', + success: () => { + showStatusSuccess('{{ __("legacy.success.ok") }}'); + setTimeout(() => { + window.location.reload(); + }, 1000); + } + }); } @@ -127,31 +304,32 @@ function confirmEmailChange(event) { echo ""; echo ""; echo ""; - echo "
"; - echo csrf_field(); echo << -
- -
-

No profanity.

-
-
- + +
+
+ +
+

No profanity.

+
+
+ +
+
- -
+ HTML; echo ""; @@ -162,22 +340,18 @@ function confirmEmailChange(event) { echo ""; echo "Allow Comments on my User Wall"; echo ""; - echo "
"; - echo csrf_field(); $checkedStr = ($userWallActive == 1) ? "checked" : ""; echo ""; - echo ""; - echo "
"; + echo ""; echo ""; echo ""; echo ""; echo "Remove all comments from my User Wall"; echo ""; - echo "
"; - echo csrf_field(); - echo ""; - echo "
"; + ?> + + "; echo ""; } @@ -301,11 +475,7 @@ function confirmEmailChange(event) {

"; - echo csrf_field(); - $checkedStr = ($userWallActive == 1) ? "checked" : ""; - echo ""; - echo ""; + echo ""; echo ""; echo ""; @@ -314,10 +484,7 @@ function confirmEmailChange(event) { echo ""; echo "

The Connect Key is used in emulators to keep you logged in.
"; echo "Resetting the key will log you out of all emulators.

"; - echo "
"; - echo csrf_field(); - $checkedStr = ($userWallActive == 1) ? "checked" : ""; - echo ""; + echo ""; echo "
"; echo ""; echo ""; @@ -327,8 +494,10 @@ function confirmEmailChange(event) { ?>

Change Password

-
- + @@ -336,15 +505,15 @@ function confirmEmailChange(event) { - + - + - + @@ -358,8 +527,11 @@ function confirmEmailChange(event) {

Change Email Address

- - +
Current Password
New Password
Confirm Password
@@ -374,13 +546,13 @@ function confirmEmailChange(event) { @@ -443,7 +615,8 @@ function GetAllResettableGamesList() { ShowLoadingIcon('loadingiconreset'); // Make API call to get game list - $.post('/request/user/list-games.php').done(data => { + $.get('{{ route('player.games.resettable') }}').done(({ results }) => { + // Create a document fragment to hold the options const fragment = new DocumentFragment(); @@ -454,10 +627,10 @@ function GetAllResettableGamesList() { fragment.appendChild(option); // Create an option for each game and append it to the fragment - for (const game of data) { + for (const game of results) { const option = document.createElement('option'); - option.value = game.ID; - option.textContent = `${game.GameTitle} (${game.ConsoleName}) (${game.NumAwarded} / ${game.NumPossible} won)`; + option.value = game.id; + option.textContent = `${game.title} (${game.consoleName}) (${game.numAwarded} / ${game.numPossible} won)`; fragment.appendChild(option); } @@ -480,14 +653,15 @@ function ResetFetchAwarded() { gameSelect.setAttribute('disabled', 'disabled'); achievementSelect.innerHTML += ''; ShowLoadingIcon('loadingiconreset'); - $.post('/request/user/list-unlocks.php', { game: gameID }) - .done(function (data) { + + $.get(`/game/${gameID}/achievements/resettable`) + .done(function ({ results }) { achievementSelect.replaceChildren(); achievementSelect.innerHTML += ''; - data.forEach(function (achievement) { - var achTitle = achievement.Title; - var achID = achievement.ID; - achievementSelect.innerHTML += ''; + results.forEach(function (achievement) { + var achTitle = achievement.title; + var achID = achievement.id; + achievementSelect.innerHTML += ''; }); gameSelect.removeAttribute('disabled'); achievementSelect.removeAttribute('disabled'); @@ -512,19 +686,24 @@ function ResetProgressForSelection() { if (gameId > 0 && confirm('Reset all achievements for "' + gameName + '"?')) { ShowLoadingIcon('loadingiconreset'); - $.post('/request/user/reset-achievements.php', { game: gameId }) - .done(function () { + + $.ajax({ + url: `/user/game/${gameId}`, + type: 'DELETE', + success: () => { ShowDoneIcon('loadingiconreset'); achievementSelect.replaceChildren(); GetAllResettableGamesList(); - }); + } + }); } } else if (achID > 0 && confirm('Reset achievement "' + achName + '"?')) { ShowLoadingIcon('loadingiconreset'); - $.post('/request/user/reset-achievements.php', { - achievement: achID, - }) - .done(function () { + + $.ajax({ + url: `/user/achievement/${achID}`, + type: 'DELETE', + success: () => { ShowDoneIcon('loadingiconreset'); if ($('#resetachievementscontainer').children('option').length > 2) { // Just reset ach. list @@ -534,7 +713,8 @@ function ResetProgressForSelection() { achievementSelect.replaceChildren(); GetAllResettableGamesList(); } - }); + } + }); } } @@ -553,15 +733,9 @@ function ResetProgressForSelection() { You requested to have your account deleted on (UTC).
Your account will be permanently deleted on .

- - - - + - - - - + @@ -595,10 +769,7 @@ function ResetProgressForSelection() {
Reset your avatar to default by removing your current one:
- - - - + @else
To upload an avatar, earn 250 points in either mode or wait until your account is at least 14 days old. diff --git a/tests/Feature/Community/Controllers/UserCommentControllerTest.php b/tests/Feature/Community/Controllers/UserCommentControllerTest.php new file mode 100644 index 0000000000..e5366816a8 --- /dev/null +++ b/tests/Feature/Community/Controllers/UserCommentControllerTest.php @@ -0,0 +1,71 @@ +withoutMiddleware(); + + /** @var User $user */ + $user = User::factory()->create([ + 'email_verified_at' => Carbon::now(), + ]); + /** @var User $otherUser */ + $otherUser = User::factory()->create(); + + Comment::factory()->create([ + 'ArticleType' => ArticleType::User, + 'ArticleID' => $otherUser->id, + ]); + + // Act + $response = $this->actingAs($user)->deleteJson(route('user.comment.destroyAll', $otherUser->id)); + + // Assert + $response->assertStatus(403); + $this->assertDatabaseHas('Comment', [ + 'ArticleType' => ArticleType::User, + 'ArticleID' => $otherUser->id, + ]); + } + + public function testDestroyAllAuthorized(): void + { + // Arrange + $this->withoutMiddleware(); + + /** @var User $user */ + $user = User::factory()->create([ + 'email_verified_at' => Carbon::now(), + ]); + + Comment::factory()->create([ + 'ArticleType' => ArticleType::User, + 'ArticleID' => $user->id, + ]); + + // Act + $response = $this->actingAs($user)->deleteJson(route('user.comment.destroyAll', $user->id)); + + // Assert + $response->assertStatus(200); + $this->assertDatabaseHas('Comment', [ + 'ArticleType' => ArticleType::User, + 'ArticleID' => $user->id, + ]); + } +} diff --git a/tests/Feature/Community/Controllers/UserSettingsControllerTest.php b/tests/Feature/Community/Controllers/UserSettingsControllerTest.php new file mode 100644 index 0000000000..c71135eca2 --- /dev/null +++ b/tests/Feature/Community/Controllers/UserSettingsControllerTest.php @@ -0,0 +1,202 @@ +withoutMiddleware(); + + /** @var User $user */ + $user = User::factory()->create([ + 'Password' => Hash::make('oldPassword123'), + 'appToken' => 'foo', + ]); + + // Act + $response = $this->actingAs($user) + ->putJson(route('settings.password.update'), [ + 'currentPassword' => 'oldPassword123', + 'newPassword' => 'newPassword123', + ]); + + // Assert + $response->assertStatus(200)->assertJson(['success' => true]); + + $user = $user->fresh(); + $this->assertTrue(Hash::check('newPassword123', $user->Password)); + $this->assertTrue($user->appToken !== 'foo'); + } + + public function testUpdatePasswordWithWrongCurrentPassword(): void + { + // Arrange + $this->withoutMiddleware(); + + /** @var User $user */ + $user = User::factory()->create([ + 'Password' => Hash::make('oldPassword123'), + 'appToken' => 'foo', + ]); + + // Act + $response = $this->actingAs($user) + ->putJson(route('settings.password.update'), [ + 'currentPassword' => '12345678', + 'newPassword' => 'newPassword123', + ]); + + // Assert + $response->assertStatus(422)->assertJson(['message' => 'Incorrect credentials.']); + } + + public function testUpdatePasswordAsUsername(): void + { + // Arrange + $this->withoutMiddleware(); + + /** @var User $user */ + $user = User::factory()->create([ + 'User' => 'MyUsername', + 'Password' => Hash::make('oldPassword123'), + 'appToken' => 'foo', + ]); + + // Act + $response = $this->actingAs($user) + ->putJson(route('settings.password.update'), [ + 'currentPassword' => 'oldPassword123', + 'newPassword' => 'MyUsername', + ]); + + // Assert + $response->assertStatus(422) + ->assertJson(['message' => 'Your password must be different from your username.']); + } + + public function testUpdateEmail(): void + { + // Arrange + $this->withoutMiddleware(); + + /** @var User $user */ + $user = User::factory()->create([ + 'EmailAddress' => 'foo@bar.com', + ]); + + // Act + $response = $this->actingAs($user) + ->putJson(route('settings.email.update'), [ + 'newEmail' => 'bar@baz.com', + ]); + + // Assert + $response->assertStatus(200); + + $user = $user->fresh(); + $this->assertEquals('bar@baz.com', $user->EmailAddress); + $this->assertEquals(Permissions::Unregistered, (int) $user->getAttribute('Permissions')); + $this->assertNull($user->email_verified_at); + } + + public function testUpdateProfile(): void + { + // Arrange + $this->withoutMiddleware(); + + /** @var User $user */ + $user = User::factory()->create([ + 'email_verified_at' => Carbon::now(), + 'Motto' => '', + 'UserWallActive' => false, + ]); + + // Act + $response = $this->actingAs($user) + ->putJson(route('settings.profile.update'), [ + 'motto' => 'New motto', + 'userWallActive' => true, + ]); + + // Assert + $response->assertStatus(200); + + $user = $user->fresh(); + $this->assertEquals('New motto', $user->Motto); + $this->assertEquals(true, $user->UserWallActive); + } + + public function testUpdatePreferences(): void + { + // Arrange + $this->withoutMiddleware(); + + /** @var User $user */ + $user = User::factory()->create([ + 'websitePrefs' => 1111, + ]); + + // Act + $response = $this->actingAs($user) + ->putJson(route('settings.preferences.update'), [ + 'websitePrefs' => 2222, + ]); + + // Assert + $response->assertStatus(200); + + $user = $user->fresh(); + $this->assertEquals(2222, $user->websitePrefs); + } + + public function testResetWebApiKey(): void + { + // Arrange + $this->withoutMiddleware(); + + /** @var User $user */ + $user = User::factory()->create([ + 'APIKey' => 'foo', + ]); + + // Act + $response = $this->actingAs($user)->deleteJson(route('settings.keys.web.destroy')); + + // Assert + $response->assertStatus(200)->assertJsonStructure(['newKey']); + $this->assertNotEquals('foo', $response->json('newKey')); + } + + public function testResetConnectApiKey(): void + { + // Arrange + $this->withoutMiddleware(); + + /** @var User $user */ + $user = User::factory()->create([ + 'appToken' => 'foo', + ]); + + // Act + $response = $this->actingAs($user)->deleteJson(route('settings.keys.connect.destroy')); + + // Assert + $response->assertStatus(200)->assertJson(['success' => true]); + + $user = $user->fresh(); + $this->assertNotEquals('foo', $user->appToken); + } +}
- +
- +