From eaedeeb52bb6c1f114e24f550a4780e2f51c10b0 Mon Sep 17 00:00:00 2001 From: sindoring Date: Fri, 13 Mar 2026 23:33:35 +0400 Subject: [PATCH] =?UTF-8?q?=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 21 +- README.md | 12 + app/Auth/AzerothCoreUserProvider.php | 56 +++ app/Exceptions/InviteCodeException.php | 20 + .../Controllers/Admin/AccountController.php | 41 ++ .../Admin/InviteCodeController.php | 21 ++ app/Http/Controllers/Auth/LoginController.php | 43 +++ app/Http/Controllers/CabinetController.php | 49 +++ app/Http/Controllers/LandingController.php | 26 +- app/Http/Middleware/EnsureGameMaster.php | 23 ++ app/Http/Requests/ChangePasswordRequest.php | 40 ++ .../Requests/CreateInviteCodesRequest.php | 36 ++ app/Http/Requests/LoginRequest.php | 40 ++ .../Requests/RegisterGameAccountRequest.php | 6 + .../Requests/UpdateAccountAccessRequest.php | 36 ++ app/Models/GameAccountUser.php | 83 +++++ app/Models/InviteCode.php | 24 ++ app/Providers/AppServiceProvider.php | 6 +- app/Services/AzerothCoreAccountRegistrar.php | 41 +- app/Services/AzerothCoreAccountService.php | 234 ++++++++++++ app/Services/AzerothCoreSrpService.php | 67 ++++ app/Services/GameClientDownloadService.php | 36 ++ app/Services/InviteCodeService.php | 112 ++++++ app/Support/WowData.php | 90 +++++ bootstrap/app.php | 4 +- composer.json | 4 + composer.lock | 349 +++++++++++++++++- config/auth.php | 20 +- config/database.php | 20 + config/moonwell.php | 12 + ...03_13_000003_create_invite_codes_table.php | 27 ++ public/site.css | 180 ++++++++- .../views/admin/accounts/index.blade.php | 166 +++++++++ resources/views/auth/login.blade.php | 61 +++ resources/views/cabinet/index.blade.php | 152 ++++++++ resources/views/landing.blade.php | 30 +- resources/views/layouts/portal.blade.php | 52 +++ routes/web.php | 27 ++ tests/Feature/AdminAccountsFeatureTest.php | 136 +++++++ tests/Feature/CabinetFeatureTest.php | 128 +++++++ tests/Feature/GameAccountRegistrationTest.php | 42 ++- 41 files changed, 2506 insertions(+), 67 deletions(-) create mode 100644 app/Auth/AzerothCoreUserProvider.php create mode 100644 app/Exceptions/InviteCodeException.php create mode 100644 app/Http/Controllers/Admin/AccountController.php create mode 100644 app/Http/Controllers/Admin/InviteCodeController.php create mode 100644 app/Http/Controllers/Auth/LoginController.php create mode 100644 app/Http/Controllers/CabinetController.php create mode 100644 app/Http/Middleware/EnsureGameMaster.php create mode 100644 app/Http/Requests/ChangePasswordRequest.php create mode 100644 app/Http/Requests/CreateInviteCodesRequest.php create mode 100644 app/Http/Requests/LoginRequest.php create mode 100644 app/Http/Requests/UpdateAccountAccessRequest.php create mode 100644 app/Models/GameAccountUser.php create mode 100644 app/Models/InviteCode.php create mode 100644 app/Services/AzerothCoreAccountService.php create mode 100644 app/Services/AzerothCoreSrpService.php create mode 100644 app/Services/GameClientDownloadService.php create mode 100644 app/Services/InviteCodeService.php create mode 100644 app/Support/WowData.php create mode 100644 database/migrations/2026_03_13_000003_create_invite_codes_table.php create mode 100644 resources/views/admin/accounts/index.blade.php create mode 100644 resources/views/auth/login.blade.php create mode 100644 resources/views/cabinet/index.blade.php create mode 100644 resources/views/layouts/portal.blade.php create mode 100644 tests/Feature/AdminAccountsFeatureTest.php create mode 100644 tests/Feature/CabinetFeatureTest.php diff --git a/.env.example b/.env.example index 496fdd0..95990ed 100644 --- a/.env.example +++ b/.env.example @@ -61,9 +61,10 @@ MAIL_FROM_NAME="${APP_NAME}" AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= -AWS_DEFAULT_REGION=us-east-1 -AWS_BUCKET= -AWS_USE_PATH_STYLE_ENDPOINT=false +AWS_DEFAULT_REGION=ru-central1 +AWS_BUCKET=warcraft-client +AWS_ENDPOINT=https://storage.yandexcloud.net +AWS_USE_PATH_STYLE_ENDPOINT=true WWWUSER=1000 WWWGROUP=1000 @@ -79,11 +80,22 @@ AZEROTHCORE_AUTH_DB_PASSWORD=password AZEROTHCORE_AUTH_DB_SOCKET= AZEROTHCORE_AUTH_DB_CHARSET=utf8mb4 AZEROTHCORE_AUTH_DB_COLLATION=utf8mb4_unicode_ci +AZEROTHCORE_CHARACTERS_CONNECTION=azerothcore_characters +AZEROTHCORE_CHARACTERS_DB_HOST=host.docker.internal +AZEROTHCORE_CHARACTERS_DB_PORT=3306 +AZEROTHCORE_CHARACTERS_DB_DATABASE=acore_characters +AZEROTHCORE_CHARACTERS_DB_USERNAME=root +AZEROTHCORE_CHARACTERS_DB_PASSWORD=password +AZEROTHCORE_CHARACTERS_DB_SOCKET= +AZEROTHCORE_CHARACTERS_DB_CHARSET=utf8mb4 +AZEROTHCORE_CHARACTERS_DB_COLLATION=utf8mb4_unicode_ci AZEROTHCORE_ACCOUNT_GMLEVEL=0 AZEROTHCORE_ACCOUNT_REALM_ID=-1 AZEROTHCORE_ACCOUNT_EXPANSION=2 AZEROTHCORE_ACCOUNT_ACCESS_COMMENT="registered via moonwell-web" AZEROTHCORE_ENFORCE_UNIQUE_EMAIL=false +MOONWELL_ADMIN_MIN_GMLEVEL=3 +MOONWELL_ADMIN_ACCESS_COMMENT="updated via moonwell-web admin" MOONWELL_SERVER_NAME=Moonwell MOONWELL_REALM_NAME=Moonwell @@ -94,5 +106,8 @@ MOONWELL_RATE_EXPERIENCE=x3 MOONWELL_RATE_GOLD=x2 MOONWELL_RATE_PROFESSION=x2 MOONWELL_RATE_REPUTATION=x1 +MOONWELL_CLIENT_DISK=s3 +MOONWELL_CLIENT_OBJECT_KEY="World of Warcraft.zip" +MOONWELL_CLIENT_URL_TTL=30 VITE_APP_NAME="${APP_NAME}" diff --git a/README.md b/README.md index 0bbc3ee..09d8f04 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ Laravel 12-приложение с WoW-лендингом и публичной - Главная страница оформлена в тёмной cinematic-стилистике с компоновкой, вдохновлённой официальным сайтом WoW. - Форма регистрации создаёт аккаунт напрямую в `acore_auth` через отдельное соединение `azerothcore_auth`. - При регистрации заполняются таблицы `account`, `realmcharacters` и `account_access`. +- Регистрация возможна только по инвайт-коду, который хранится в базе сайта. +- Добавлены личный кабинет и админка поверх игровых аккаунтов из auth-базы. +- В кабинете доступны смена пароля, список персонажей и ссылка на клиент игры из S3. +- В админке доступны просмотр всех аккаунтов, их персонажей, смена `gmlevel` и выпуск инвайт-кодов. - Проект подготовлен под Laravel Sail: приложение работает в контейнере, а AzerothCore auth DB берётся с хоста через `host.docker.internal:3306`. ## Как запускать через Sail @@ -89,12 +93,20 @@ chmod +x scripts/deploy-ubuntu.sh - `AZEROTHCORE_AUTH_DB_DATABASE=acore_auth` - `AZEROTHCORE_AUTH_DB_USERNAME=root` - `AZEROTHCORE_AUTH_DB_PASSWORD=password` +- `AZEROTHCORE_CHARACTERS_DB_DATABASE=acore_characters` +- `MOONWELL_ADMIN_MIN_GMLEVEL=3` +- `MOONWELL_CLIENT_OBJECT_KEY="World of Warcraft.zip"` +- `AWS_ENDPOINT=https://storage.yandexcloud.net` +- `AWS_BUCKET=warcraft-client` ## Архитектура - `compose.yaml` — Sail-окружение с `laravel.test` и локальным MySQL для самого сайта. - `config/database.php` — отдельное соединение `azerothcore_auth`. - `config/moonwell.php` — настройки realm/landing и регистрации. +- `app/Auth/AzerothCoreUserProvider.php` — Laravel-auth поверх игровых аккаунтов. +- `app/Services/AzerothCoreAccountService.php` — работа с аккаунтами, персонажами, паролями и `gmlevel`. +- `app/Services/GameClientDownloadService.php` — генерация ссылки на клиент из S3. - `app/Services/AzerothCoreAccountRegistrar.php` — SRP6-совместимое создание учётной записи AzerothCore. - `app/Http/Controllers/LandingController.php` — данные лендинга и обработка формы. diff --git a/app/Auth/AzerothCoreUserProvider.php b/app/Auth/AzerothCoreUserProvider.php new file mode 100644 index 0000000..034cc5b --- /dev/null +++ b/app/Auth/AzerothCoreUserProvider.php @@ -0,0 +1,56 @@ +accounts->findUserById((int) $identifier); + } + + public function retrieveByToken($identifier, #[\SensitiveParameter] $token): ?Authenticatable + { + return null; + } + + public function updateRememberToken(Authenticatable $user, #[\SensitiveParameter] $token): void + { + } + + public function retrieveByCredentials(#[\SensitiveParameter] array $credentials): ?Authenticatable + { + $username = $credentials['username'] ?? null; + + if (! is_string($username) || trim($username) === '') { + return null; + } + + return $this->accounts->findUserByUsername($username); + } + + public function validateCredentials(Authenticatable $user, #[\SensitiveParameter] array $credentials): bool + { + return $user instanceof GameAccountUser + && isset($credentials['password']) + && is_string($credentials['password']) + && $this->accounts->validatePassword($user, $credentials['password']); + } + + public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false): void + { + } +} diff --git a/app/Exceptions/InviteCodeException.php b/app/Exceptions/InviteCodeException.php new file mode 100644 index 0000000..f9e2858 --- /dev/null +++ b/app/Exceptions/InviteCodeException.php @@ -0,0 +1,20 @@ +query('search', '')); + + return view('admin.accounts.index', [ + 'accounts' => $accounts->listAccounts($search !== '' ? $search : null), + 'inviteCodes' => $inviteCodes->latest(), + 'inviteStats' => $inviteCodes->stats(), + 'search' => $search, + 'accessLevels' => WowData::securityLevels(), + ]); + } + + public function updateAccess(int $accountId, UpdateAccountAccessRequest $request, AzerothCoreAccountService $accounts): RedirectResponse + { + abort_unless($accounts->findUserById($accountId), 404); + + $accounts->updateAccessLevel($accountId, (int) $request->input('gmlevel')); + + return back()->with('status', 'Уровень доступа обновлён.'); + } +} diff --git a/app/Http/Controllers/Admin/InviteCodeController.php b/app/Http/Controllers/Admin/InviteCodeController.php new file mode 100644 index 0000000..dd66d50 --- /dev/null +++ b/app/Http/Controllers/Admin/InviteCodeController.php @@ -0,0 +1,21 @@ +createCodes( + (int) $request->integer('quantity'), + $request->user(), + ); + + return back()->with('status', sprintf('Создано инвайт-кодов: %d.', $created->count())); + } +} diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php new file mode 100644 index 0000000..9867f70 --- /dev/null +++ b/app/Http/Controllers/Auth/LoginController.php @@ -0,0 +1,43 @@ +safe()->only(['username', 'password']))) { + return back() + ->withErrors(['username' => 'Неверный логин или пароль.']) + ->onlyInput('username'); + } + + $request->session()->regenerate(); + + return redirect()->intended(route('cabinet.index')); + } + + public function destroy(Request $request): RedirectResponse + { + Auth::logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect() + ->route('login') + ->with('status', 'Сеанс завершён. Можно войти снова.'); + } +} diff --git a/app/Http/Controllers/CabinetController.php b/app/Http/Controllers/CabinetController.php new file mode 100644 index 0000000..e2cf657 --- /dev/null +++ b/app/Http/Controllers/CabinetController.php @@ -0,0 +1,49 @@ +user(); + + return view('cabinet.index', [ + 'user' => $user, + 'characters' => $accounts->charactersForAccount($user->id), + 'clientFilename' => config('moonwell.client.object_key'), + ]); + } + + public function updatePassword(ChangePasswordRequest $request, AzerothCoreAccountService $accounts): RedirectResponse + { + $user = $request->user(); + + if (! $accounts->validatePassword($user, $request->string('current_password')->toString())) { + return back()->withErrors(['current_password' => 'Текущий пароль указан неверно.']); + } + + $accounts->updatePassword($user, $request->string('password')->toString()); + + return back()->with('status', 'Пароль успешно обновлён.'); + } + + public function client(GameClientDownloadService $clientDownloadService): RedirectResponse + { + try { + return redirect()->away($clientDownloadService->temporaryUrl()); + } catch (Throwable $exception) { + report($exception); + + return back()->with('error', 'Не удалось получить ссылку на клиент. Попробуй ещё раз позже.'); + } + } +} diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index 58503dc..82f3ab3 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -3,8 +3,10 @@ namespace App\Http\Controllers; use App\Exceptions\DuplicateGameAccountException; +use App\Exceptions\InviteCodeException; use App\Http\Requests\RegisterGameAccountRequest; use App\Services\AzerothCoreAccountRegistrar; +use App\Services\InviteCodeService; use Illuminate\Http\RedirectResponse; use Illuminate\View\View; use Throwable; @@ -85,24 +87,36 @@ class LandingController extends Controller ]); } - public function register(RegisterGameAccountRequest $request, AzerothCoreAccountRegistrar $registrar): RedirectResponse + public function register( + RegisterGameAccountRequest $request, + AzerothCoreAccountRegistrar $registrar, + InviteCodeService $inviteCodes, + ): RedirectResponse { try { - $result = $registrar->register( + $result = $inviteCodes->redeemForRegistration( + $request->string('invite_code')->toString(), $request->string('username')->toString(), - $request->string('email')->toString(), - $request->string('password')->toString(), + fn (): array => $registrar->register( + $request->string('username')->toString(), + $request->string('email')->toString(), + $request->string('password')->toString(), + ), ); } catch (DuplicateGameAccountException $exception) { return back() ->withErrors([$exception->field => $exception->getMessage()]) - ->withInput($request->safe()->only(['username', 'email'])); + ->withInput($request->safe()->only(['username', 'email', 'invite_code'])); + } catch (InviteCodeException $exception) { + return back() + ->withErrors([$exception->field => $exception->getMessage()]) + ->withInput($request->safe()->only(['username', 'email', 'invite_code'])); } catch (Throwable $exception) { report($exception); return back() ->with('registration_error', 'Не удалось создать аккаунт. Проверь настройки сервера и попробуй снова.') - ->withInput($request->safe()->only(['username', 'email'])); + ->withInput($request->safe()->only(['username', 'email', 'invite_code'])); } return redirect() diff --git a/app/Http/Middleware/EnsureGameMaster.php b/app/Http/Middleware/EnsureGameMaster.php new file mode 100644 index 0000000..f850208 --- /dev/null +++ b/app/Http/Middleware/EnsureGameMaster.php @@ -0,0 +1,23 @@ +user(); + + abort_unless( + $user && $user->canAccessAdminPanel(), + Response::HTTP_FORBIDDEN, + 'Недостаточно прав для доступа к админке.', + ); + + return $next($request); + } +} diff --git a/app/Http/Requests/ChangePasswordRequest.php b/app/Http/Requests/ChangePasswordRequest.php new file mode 100644 index 0000000..e11dffd --- /dev/null +++ b/app/Http/Requests/ChangePasswordRequest.php @@ -0,0 +1,40 @@ +|string> + */ + public function rules(): array + { + return [ + 'current_password' => ['required', 'string', 'max:32'], + 'password' => ['required', 'string', 'min:8', 'max:32', 'confirmed', 'regex:/^(?=.*[A-Za-z])(?=.*\d).+$/'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'current_password.required' => 'Укажи текущий пароль.', + 'current_password.max' => 'Текущий пароль не должен быть длиннее 32 символов.', + 'password.required' => 'Укажи новый пароль.', + 'password.min' => 'Новый пароль должен содержать минимум 8 символов.', + 'password.max' => 'Новый пароль не должен быть длиннее 32 символов.', + 'password.confirmed' => 'Подтверждение нового пароля не совпадает.', + 'password.regex' => 'Новый пароль должен содержать хотя бы одну букву и одну цифру.', + ]; + } +} diff --git a/app/Http/Requests/CreateInviteCodesRequest.php b/app/Http/Requests/CreateInviteCodesRequest.php new file mode 100644 index 0000000..ac60fdb --- /dev/null +++ b/app/Http/Requests/CreateInviteCodesRequest.php @@ -0,0 +1,36 @@ +|string> + */ + public function rules(): array + { + return [ + 'quantity' => ['required', 'integer', 'min:1', 'max:50'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'quantity.required' => 'Укажи количество инвайт-кодов.', + 'quantity.integer' => 'Количество должно быть числом.', + 'quantity.min' => 'Нужно создать минимум один инвайт-код.', + 'quantity.max' => 'За один раз можно создать не больше 50 инвайт-кодов.', + ]; + } +} diff --git a/app/Http/Requests/LoginRequest.php b/app/Http/Requests/LoginRequest.php new file mode 100644 index 0000000..668866e --- /dev/null +++ b/app/Http/Requests/LoginRequest.php @@ -0,0 +1,40 @@ +|string> + */ + public function rules(): array + { + return [ + 'username' => ['required', 'string', 'min:3', 'max:32', 'regex:/^[A-Za-z0-9]+$/'], + 'password' => ['required', 'string', 'min:3', 'max:32'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'username.required' => 'Укажи логин аккаунта.', + 'username.min' => 'Логин должен содержать минимум 3 символа.', + 'username.max' => 'Логин не должен быть длиннее 32 символов.', + 'username.regex' => 'Логин может содержать только латинские буквы и цифры.', + 'password.required' => 'Укажи пароль.', + 'password.min' => 'Пароль должен содержать минимум 3 символа.', + 'password.max' => 'Пароль не должен быть длиннее 32 символов.', + ]; + } +} diff --git a/app/Http/Requests/RegisterGameAccountRequest.php b/app/Http/Requests/RegisterGameAccountRequest.php index ee54c34..8765d1a 100644 --- a/app/Http/Requests/RegisterGameAccountRequest.php +++ b/app/Http/Requests/RegisterGameAccountRequest.php @@ -19,6 +19,7 @@ class RegisterGameAccountRequest extends FormRequest return [ 'username' => ['required', 'string', 'min:3', 'max:32', 'regex:/^[A-Za-z0-9]+$/'], 'email' => ['required', 'string', 'email:rfc', 'max:255'], + 'invite_code' => ['required', 'string', 'min:6', 'max:32'], 'password' => ['required', 'string', 'min:8', 'max:32', 'confirmed', 'regex:/^(?=.*[A-Za-z])(?=.*\d).+$/'], 'terms' => ['accepted'], ]; @@ -39,6 +40,10 @@ class RegisterGameAccountRequest extends FormRequest 'email.string' => 'Email должен быть строкой.', 'email.email' => 'Укажи корректный email адрес.', 'email.max' => 'Email не должен быть длиннее 255 символов.', + 'invite_code.required' => 'Укажи инвайт-код.', + 'invite_code.string' => 'Инвайт-код должен быть строкой.', + 'invite_code.min' => 'Инвайт-код выглядит слишком коротким.', + 'invite_code.max' => 'Инвайт-код выглядит слишком длинным.', 'password.required' => 'Укажи пароль.', 'password.string' => 'Пароль должен быть строкой.', 'password.min' => 'Пароль должен содержать минимум 8 символов.', @@ -57,6 +62,7 @@ class RegisterGameAccountRequest extends FormRequest return [ 'username' => 'логин аккаунта', 'email' => 'email', + 'invite_code' => 'инвайт-код', 'password' => 'пароль', 'terms' => 'правила', ]; diff --git a/app/Http/Requests/UpdateAccountAccessRequest.php b/app/Http/Requests/UpdateAccountAccessRequest.php new file mode 100644 index 0000000..5e2dc70 --- /dev/null +++ b/app/Http/Requests/UpdateAccountAccessRequest.php @@ -0,0 +1,36 @@ +|string> + */ + public function rules(): array + { + return [ + 'gmlevel' => ['required', 'integer', Rule::in(array_keys(\App\Support\WowData::securityLevels()))], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'gmlevel.required' => 'Укажи уровень доступа.', + 'gmlevel.integer' => 'Уровень доступа должен быть числом.', + 'gmlevel.in' => 'Выбран недопустимый уровень доступа.', + ]; + } +} diff --git a/app/Models/GameAccountUser.php b/app/Models/GameAccountUser.php new file mode 100644 index 0000000..82c8c90 --- /dev/null +++ b/app/Models/GameAccountUser.php @@ -0,0 +1,83 @@ +id; + } + + public function getAuthPasswordName(): string + { + return 'verifier'; + } + + public function getAuthPassword(): string + { + return $this->verifierHex; + } + + public function getRememberToken(): string + { + return ''; + } + + public function setRememberToken($value): void + { + } + + public function getRememberTokenName(): string + { + return 'remember_token'; + } + + public function accessLabel(): string + { + return WowData::securityLevelName($this->gmLevel); + } + + public function canAccessAdminPanel(): bool + { + return $this->gmLevel >= config('moonwell.portal.admin_min_gm_level'); + } + + public function joinedAtLabel(): string + { + return $this->formatDate($this->joinedAt); + } + + public function lastLoginLabel(): string + { + return $this->formatDate($this->lastLoginAt); + } + + private function formatDate(?string $value): string + { + return $value ? Carbon::parse($value)->format('d.m.Y H:i') : '—'; + } +} diff --git a/app/Models/InviteCode.php b/app/Models/InviteCode.php new file mode 100644 index 0000000..9ae0dcd --- /dev/null +++ b/app/Models/InviteCode.php @@ -0,0 +1,24 @@ + 'datetime', + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..0d009d7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,9 @@ namespace App\Providers; +use App\Auth\AzerothCoreUserProvider; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Facades\Auth; class AppServiceProvider extends ServiceProvider { @@ -19,6 +21,8 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + Auth::provider('azerothcore', function ($app): AzerothCoreUserProvider { + return $app->make(AzerothCoreUserProvider::class); + }); } } diff --git a/app/Services/AzerothCoreAccountRegistrar.php b/app/Services/AzerothCoreAccountRegistrar.php index 820f248..05f98e4 100644 --- a/app/Services/AzerothCoreAccountRegistrar.php +++ b/app/Services/AzerothCoreAccountRegistrar.php @@ -3,28 +3,25 @@ namespace App\Services; use App\Exceptions\DuplicateGameAccountException; -use Brick\Math\BigInteger; use Illuminate\Database\ConnectionInterface; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; class AzerothCoreAccountRegistrar { - private const string MODULUS_HEX = '894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7'; - - private const int GENERATOR = 7; - - private const int BYTE_LENGTH = 32; + public function __construct(private readonly AzerothCoreSrpService $srp) + { + } /** * @return array{id: int, username: string} */ public function register(string $username, string $email, string $password): array { - $normalizedUsername = $this->normalizeUpperOnlyLatin(trim($username)); - $normalizedPassword = $this->normalizeUpperOnlyLatin($password); - $normalizedEmail = $this->normalizeUpperOnlyLatin(trim($email)); - [$saltHex, $verifierHex] = $this->makeRegistrationData($normalizedUsername, $normalizedPassword); + $normalizedUsername = $this->srp->normalizeUsername($username); + $normalizedPassword = $this->srp->normalizePassword($password); + $normalizedEmail = $this->srp->normalizeEmail($email); + [$saltHex, $verifierHex] = $this->srp->makeRegistrationData($normalizedUsername, $normalizedPassword); $connection = DB::connection(config('moonwell.auth_connection')); @@ -97,30 +94,6 @@ class AzerothCoreAccountRegistrar return $connection->table('account')->where($column, $value)->exists(); } - /** - * @return array{0: string, 1: string} - */ - private function makeRegistrationData(string $username, string $password): array - { - $salt = random_bytes(self::BYTE_LENGTH); - $innerDigest = hash('sha1', sprintf('%s:%s', $username, $password), true); - $xDigest = hash('sha1', $salt.$innerDigest, true); - $modulus = BigInteger::fromBase(self::MODULUS_HEX, 16); - $exponent = BigInteger::fromBytes(strrev($xDigest), false); - $verifier = BigInteger::of(self::GENERATOR)->modPow($exponent, $modulus); - $verifierBytes = strrev(str_pad($verifier->toBytes(false), self::BYTE_LENGTH, "\0", STR_PAD_LEFT)); - - return [ - strtoupper(bin2hex($salt)), - strtoupper(bin2hex($verifierBytes)), - ]; - } - - private function normalizeUpperOnlyLatin(string $value): string - { - return strtoupper($value); - } - private function isDuplicateKeyException(QueryException $exception): bool { return (string) $exception->getCode() === '23000'; diff --git a/app/Services/AzerothCoreAccountService.php b/app/Services/AzerothCoreAccountService.php new file mode 100644 index 0000000..13f1d9d --- /dev/null +++ b/app/Services/AzerothCoreAccountService.php @@ -0,0 +1,234 @@ +authConnection() + ->table('account') + ->selectRaw('id, username, email, reg_mail, locked, last_login, joindate, HEX(salt) as salt_hex, HEX(verifier) as verifier_hex') + ->where('id', $accountId) + ->first(); + + if (! $record) { + return null; + } + + $gmLevel = $this->accessLevelsFor([$accountId])[$accountId] ?? 0; + + return $this->mapUser($record, $gmLevel); + } + + public function findUserByUsername(string $username): ?GameAccountUser + { + $record = $this->authConnection() + ->table('account') + ->selectRaw('id, username, email, reg_mail, locked, last_login, joindate, HEX(salt) as salt_hex, HEX(verifier) as verifier_hex') + ->where('username', $this->srp->normalizeUsername($username)) + ->first(); + + if (! $record) { + return null; + } + + $gmLevel = $this->accessLevelsFor([(int) $record->id])[(int) $record->id] ?? 0; + + return $this->mapUser($record, $gmLevel); + } + + public function validatePassword(GameAccountUser $user, string $password): bool + { + return $this->srp->credentialsMatch($user->username, $password, $user->saltHex, $user->verifierHex); + } + + public function updatePassword(GameAccountUser $user, string $newPassword): void + { + [$saltHex, $verifierHex] = $this->srp->makeRegistrationData($user->username, $newPassword); + + $this->authConnection()->update( + 'UPDATE account SET salt = UNHEX(?), verifier = UNHEX(?), session_key = NULL, failed_logins = 0 WHERE id = ?', + [$saltHex, $verifierHex, $user->id], + ); + } + + /** + * @return \Illuminate\Support\Collection> + */ + public function listAccounts(?string $search = null): Collection + { + $query = $this->authConnection() + ->table('account') + ->select('id', 'username', 'email', 'reg_mail', 'locked', 'last_login', 'joindate', 'online') + ->orderByDesc('id'); + + if ($search !== null && trim($search) !== '') { + $normalizedSearch = $this->srp->normalizeUsername($search); + + $query->where(function ($builder) use ($normalizedSearch): void { + $builder + ->where('username', 'like', "%{$normalizedSearch}%") + ->orWhere('email', 'like', "%{$normalizedSearch}%") + ->orWhere('reg_mail', 'like', "%{$normalizedSearch}%"); + }); + } + + $accounts = $query->get(); + $accountIds = $accounts->pluck('id')->map(fn ($id): int => (int) $id)->all(); + $accessLevels = $this->accessLevelsFor($accountIds); + $characters = $this->charactersForAccountIds($accountIds); + + return $accounts->map(function (object $account) use ($accessLevels, $characters): array { + $accountId = (int) $account->id; + $gmLevel = $accessLevels[$accountId] ?? 0; + + return [ + 'id' => $accountId, + 'username' => $account->username, + 'email' => $account->email, + 'reg_mail' => $account->reg_mail, + 'gm_level' => $gmLevel, + 'access_label' => WowData::securityLevelName($gmLevel), + 'locked' => (bool) $account->locked, + 'online' => (bool) $account->online, + 'joined_at' => $this->formatDate($account->joindate), + 'last_login_at' => $this->formatDate($account->last_login), + 'characters' => $characters->get($accountId, collect()), + ]; + }); + } + + /** + * @return \Illuminate\Support\Collection> + */ + public function charactersForAccount(int $accountId): Collection + { + return $this->charactersForAccountIds([$accountId])->get($accountId, collect()); + } + + public function updateAccessLevel(int $accountId, int $gmLevel): void + { + $realmId = $this->targetRealmId(); + + $this->authConnection()->statement( + 'INSERT INTO account_access (id, gmlevel, RealmID, comment) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE gmlevel = VALUES(gmlevel), comment = VALUES(comment)', + [ + $accountId, + $gmLevel, + $realmId, + config('moonwell.portal.access_comment'), + ], + ); + } + + /** + * @param array $accountIds + * @return \Illuminate\Support\Collection>> + */ + private function charactersForAccountIds(array $accountIds): Collection + { + if ($accountIds === []) { + return collect(); + } + + return $this->charactersConnection() + ->table('characters') + ->select('guid', 'account', 'name', 'race', 'class', 'gender', 'level', 'online', 'money', 'totaltime') + ->whereIn('account', $accountIds) + ->orderBy('account') + ->orderByDesc('level') + ->orderBy('name') + ->get() + ->map(function (object $character): array { + return [ + 'guid' => (int) $character->guid, + 'account' => (int) $character->account, + 'name' => $character->name, + 'level' => (int) $character->level, + 'race' => WowData::raceName((int) $character->race), + 'class' => WowData::className((int) $character->class), + 'gender' => WowData::genderName((int) $character->gender), + 'online' => (bool) $character->online, + 'money' => WowData::formatMoney((int) $character->money), + 'played' => WowData::formatPlayedTime((int) $character->totaltime), + ]; + }) + ->groupBy('account') + ->map(fn (Collection $group): Collection => $group->values()); + } + + /** + * @param array $accountIds + * @return array + */ + private function accessLevelsFor(array $accountIds): array + { + if ($accountIds === []) { + return []; + } + + $targetRealmId = $this->targetRealmId(); + $realmIds = array_values(array_unique([$targetRealmId, -1])); + + return $this->authConnection() + ->table('account_access') + ->select('id', 'gmlevel', 'RealmID') + ->whereIn('id', $accountIds) + ->whereIn('RealmID', $realmIds) + ->get() + ->groupBy('id') + ->map(function (Collection $rows) use ($targetRealmId): int { + $exactMatch = $rows->firstWhere('RealmID', $targetRealmId); + + return (int) ($exactMatch->gmlevel ?? $rows->firstWhere('RealmID', -1)?->gmlevel ?? 0); + }) + ->all(); + } + + private function mapUser(object $record, int $gmLevel): GameAccountUser + { + return new GameAccountUser( + id: (int) $record->id, + username: $record->username, + email: $record->email, + regMail: $record->reg_mail, + gmLevel: $gmLevel, + locked: (bool) $record->locked, + lastLoginAt: $record->last_login, + joinedAt: $record->joindate, + saltHex: strtoupper($record->salt_hex), + verifierHex: strtoupper($record->verifier_hex), + ); + } + + private function formatDate(?string $value): string + { + return $value ? Carbon::parse($value)->format('d.m.Y H:i') : '—'; + } + + private function targetRealmId(): int + { + return (int) config('moonwell.registration.realm_id', -1); + } + + private function authConnection() + { + return DB::connection(config('moonwell.auth_connection')); + } + + private function charactersConnection() + { + return DB::connection(config('moonwell.characters_connection')); + } +} diff --git a/app/Services/AzerothCoreSrpService.php b/app/Services/AzerothCoreSrpService.php new file mode 100644 index 0000000..b774273 --- /dev/null +++ b/app/Services/AzerothCoreSrpService.php @@ -0,0 +1,67 @@ +makeVerifierHex($username, $password, $saltHex)]; + } + + public function makeVerifierHex(string $username, string $password, string $saltHex): string + { + $normalizedUsername = $this->normalizeUsername($username); + $normalizedPassword = $this->normalizePassword($password); + $salt = hex2bin($saltHex); + + if ($salt === false) { + throw new \InvalidArgumentException('Invalid salt hex provided.'); + } + + $innerDigest = hash('sha1', sprintf('%s:%s', $normalizedUsername, $normalizedPassword), true); + $xDigest = hash('sha1', $salt.$innerDigest, true); + $modulus = BigInteger::fromBase(self::MODULUS_HEX, 16); + $exponent = BigInteger::fromBytes(strrev($xDigest), false); + $verifier = BigInteger::of(self::GENERATOR)->modPow($exponent, $modulus); + $verifierBytes = strrev(str_pad($verifier->toBytes(false), self::BYTE_LENGTH, "\0", STR_PAD_LEFT)); + + return strtoupper(bin2hex($verifierBytes)); + } + + public function credentialsMatch(string $username, string $password, string $saltHex, string $verifierHex): bool + { + return hash_equals( + strtoupper($verifierHex), + $this->makeVerifierHex($username, $password, $saltHex), + ); + } +} diff --git a/app/Services/GameClientDownloadService.php b/app/Services/GameClientDownloadService.php new file mode 100644 index 0000000..caa9e2a --- /dev/null +++ b/app/Services/GameClientDownloadService.php @@ -0,0 +1,36 @@ +objectKey()); + } + + public function temporaryUrl(): string + { + $disk = Storage::disk(config('moonwell.client.disk')); + $objectKey = $this->objectKey(); + $filename = $this->filename(); + + try { + return $disk->temporaryUrl( + $objectKey, + now()->addMinutes(config('moonwell.client.temporary_url_minutes')), + ['ResponseContentDisposition' => sprintf('attachment; filename="%s"', $filename)], + ); + } catch (Throwable) { + return $disk->url($objectKey); + } + } + + private function objectKey(): string + { + return (string) config('moonwell.client.object_key'); + } +} diff --git a/app/Services/InviteCodeService.php b/app/Services/InviteCodeService.php new file mode 100644 index 0000000..7d3af66 --- /dev/null +++ b/app/Services/InviteCodeService.php @@ -0,0 +1,112 @@ + + */ + public function createCodes(int $quantity, GameAccountUser $creator): Collection + { + $created = collect(); + + for ($index = 0; $index < $quantity; $index++) { + $created->push(InviteCode::query()->create([ + 'code' => $this->generateUniqueCode(), + 'created_by_account_id' => $creator->id, + 'created_by_username' => $creator->username, + ])); + } + + return $created; + } + + /** + * @template TReturn + * + * @param Closure(): TReturn $callback + * @return TReturn + */ + public function redeemForRegistration(string $code, string $username, Closure $callback): mixed + { + $normalizedCode = $this->normalizeCode($code); + $normalizedUsername = strtoupper(trim($username)); + + return DB::transaction(function () use ($callback, $normalizedCode, $normalizedUsername) { + /** @var InviteCode|null $invite */ + $invite = InviteCode::query() + ->where('code', $normalizedCode) + ->lockForUpdate() + ->first(); + + if (! $invite) { + throw InviteCodeException::invalid(); + } + + if ($invite->redeemed_at !== null) { + throw InviteCodeException::alreadyUsed(); + } + + $result = $callback(); + + $invite->forceFill([ + 'redeemed_by_account_id' => $result['id'], + 'redeemed_by_username' => $normalizedUsername, + 'redeemed_at' => now(), + ])->save(); + + return $result; + }); + } + + /** + * @return \Illuminate\Support\Collection + */ + public function latest(int $limit = 100): Collection + { + return InviteCode::query() + ->latest('id') + ->limit($limit) + ->get(); + } + + /** + * @return array{total: int, available: int, redeemed: int} + */ + public function stats(): array + { + $total = InviteCode::query()->count(); + $redeemed = InviteCode::query()->whereNotNull('redeemed_at')->count(); + + return [ + 'total' => $total, + 'available' => $total - $redeemed, + 'redeemed' => $redeemed, + ]; + } + + private function generateUniqueCode(): string + { + do { + $code = $this->normalizeCode( + Str::upper(Str::random(4)).'-'.Str::upper(Str::random(4)).'-'.Str::upper(Str::random(4)), + ); + } while (InviteCode::query()->where('code', $code)->exists()); + + return $code; + } + + private function normalizeCode(string $code): string + { + return Str::upper(trim($code)); + } +} diff --git a/app/Support/WowData.php b/app/Support/WowData.php new file mode 100644 index 0000000..a4f8760 --- /dev/null +++ b/app/Support/WowData.php @@ -0,0 +1,90 @@ + + */ + public static function securityLevels(): array + { + return [ + 0 => 'Игрок', + 1 => 'Модератор', + 2 => 'Гейммастер', + 3 => 'Администратор', + ]; + } + + public static function securityLevelName(int $level): string + { + return self::securityLevels()[$level] ?? 'Неизвестно'; + } + + public static function raceName(int $race): string + { + return [ + 1 => 'Человек', + 2 => 'Орк', + 3 => 'Дворф', + 4 => 'Ночной эльф', + 5 => 'Нежить', + 6 => 'Таурен', + 7 => 'Гном', + 8 => 'Тролль', + 10 => 'Эльф крови', + 11 => 'Дреней', + ][$race] ?? 'Неизвестно'; + } + + public static function className(int $class): string + { + return [ + 1 => 'Воин', + 2 => 'Паладин', + 3 => 'Охотник', + 4 => 'Разбойник', + 5 => 'Жрец', + 6 => 'Рыцарь смерти', + 7 => 'Шаман', + 8 => 'Маг', + 9 => 'Чернокнижник', + 11 => 'Друид', + ][$class] ?? 'Неизвестно'; + } + + public static function genderName(int $gender): string + { + return [ + 0 => 'Мужской', + 1 => 'Женский', + ][$gender] ?? 'Неизвестно'; + } + + public static function formatMoney(int $copper): string + { + $gold = intdiv($copper, 10000); + $silver = intdiv($copper % 10000, 100); + $bronze = $copper % 100; + + return sprintf('%dз %dс %dм', $gold, $silver, $bronze); + } + + public static function formatPlayedTime(int $seconds): string + { + $days = intdiv($seconds, 86400); + $hours = intdiv($seconds % 86400, 3600); + $minutes = intdiv($seconds % 3600, 60); + + if ($days > 0) { + return sprintf('%dд %dч', $days, $hours); + } + + if ($hours > 0) { + return sprintf('%dч %dм', $hours, $minutes); + } + + return sprintf('%dм', $minutes); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..1fbbc19 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -11,7 +11,9 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'gamemaster' => \App\Http\Middleware\EnsureGameMaster::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index f729c1f..39ad695 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "license": "MIT", "require": { "php": "^8.2", + "league/flysystem-aws-s3-v3": "^3.29", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1" }, @@ -76,6 +77,9 @@ } }, "config": { + "platform": { + "php": "8.3.30" + }, "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true, diff --git a/composer.lock b/composer.lock index 0b9946c..9be19b3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,159 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "53b5d56b3b7e3cbac1713e68c8850f6c", + "content-hash": "e2e6a80574bb3ef4ce8387760252b91a", "packages": [ + { + "name": "aws/aws-crt-php", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.373.2", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "483fba51c28b3a0c0647bf5100e0edca82090b18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/483fba51c28b3a0c0647bf5100e0edca82090b18", + "reference": "483fba51c28b3a0c0647bf5100e0edca82090b18", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/promises": "^2.0", + "guzzlehttp/psr7": "^2.4.5", + "mtdowling/jmespath.php": "^2.8.0", + "php": ">=8.1", + "psr/http-message": "^1.0 || ^2.0", + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^2.7.8", + "dms/phpunit-arraysubset-asserts": "^v0.5.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-sockets": "*", + "phpunit/phpunit": "^10.0", + "psr/cache": "^2.0 || ^3.0", + "psr/simple-cache": "^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-pcntl": "To use client-side monitoring", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "https://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "https://aws.amazon.com/sdk-for-php", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://github.com/aws/aws-sdk-php/discussions", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.373.2" + }, + "time": "2026-03-13T18:08:30+00:00" + }, { "name": "brick/math", "version": "0.14.8", @@ -1733,6 +1884,61 @@ }, "time": "2026-02-25T17:01:41+00:00" }, + { + "name": "league/flysystem-aws-s3-v3", + "version": "3.32.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git", + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0", + "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0", + "shasum": "" + }, + "require": { + "aws/aws-sdk-php": "^3.295.10", + "league/flysystem": "^3.10.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\AwsS3V3\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "AWS S3 filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "aws", + "file", + "files", + "filesystem", + "s3", + "storage" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0" + }, + "time": "2026-02-25T16:46:44+00:00" + }, { "name": "league/flysystem-local", "version": "3.31.0", @@ -2123,6 +2329,72 @@ ], "time": "2026-01-02T08:56:05+00:00" }, + { + "name": "mtdowling/jmespath.php", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.33" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.8.0" + }, + "time": "2024-09-04T18:46:31+00:00" + }, { "name": "nesbot/carbon", "version": "3.11.2", @@ -3850,6 +4122,76 @@ ], "time": "2024-09-25T14:21:43+00:00" }, + { + "name": "symfony/filesystem", + "version": "v7.4.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/3ebc794fa5315e59fd122561623c2e2e4280538e", + "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-02-25T16:50:00+00:00" + }, { "name": "symfony/finder", "version": "v7.4.6", @@ -8395,5 +8737,8 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "platform-overrides": { + "php": "8.3.30" + }, + "plugin-api-version": "2.9.0" } diff --git a/config/auth.php b/config/auth.php index d7568ff..eb244dc 100644 --- a/config/auth.php +++ b/config/auth.php @@ -1,7 +1,5 @@ [ 'guard' => env('AUTH_GUARD', 'web'), - 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'game_accounts'), ], /* @@ -40,7 +38,7 @@ return [ 'guards' => [ 'web' => [ 'driver' => 'session', - 'provider' => 'users', + 'provider' => 'game_accounts', ], ], @@ -62,15 +60,9 @@ return [ */ 'providers' => [ - 'users' => [ - 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', User::class), + 'game_accounts' => [ + 'driver' => 'azerothcore', ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], ], /* @@ -93,8 +85,8 @@ return [ */ 'passwords' => [ - 'users' => [ - 'provider' => 'users', + 'game_accounts' => [ + 'provider' => 'game_accounts', 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), 'expire' => 60, 'throttle' => 60, diff --git a/config/database.php b/config/database.php index 4f61e8c..5629b84 100644 --- a/config/database.php +++ b/config/database.php @@ -84,6 +84,26 @@ return [ ]) : [], ], + 'azerothcore_characters' => [ + 'driver' => 'mysql', + 'url' => env('AZEROTHCORE_CHARACTERS_DB_URL'), + 'host' => env('AZEROTHCORE_CHARACTERS_DB_HOST', env('AZEROTHCORE_AUTH_DB_HOST', '127.0.0.1')), + 'port' => env('AZEROTHCORE_CHARACTERS_DB_PORT', env('AZEROTHCORE_AUTH_DB_PORT', '3306')), + 'database' => env('AZEROTHCORE_CHARACTERS_DB_DATABASE', 'acore_characters'), + 'username' => env('AZEROTHCORE_CHARACTERS_DB_USERNAME', env('AZEROTHCORE_AUTH_DB_USERNAME', 'root')), + 'password' => env('AZEROTHCORE_CHARACTERS_DB_PASSWORD', env('AZEROTHCORE_AUTH_DB_PASSWORD', 'password')), + 'unix_socket' => env('AZEROTHCORE_CHARACTERS_DB_SOCKET', ''), + 'charset' => env('AZEROTHCORE_CHARACTERS_DB_CHARSET', env('AZEROTHCORE_AUTH_DB_CHARSET', 'utf8mb4')), + 'collation' => env('AZEROTHCORE_CHARACTERS_DB_COLLATION', env('AZEROTHCORE_AUTH_DB_COLLATION', 'utf8mb4_unicode_ci')), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('AZEROTHCORE_CHARACTERS_DB_SSL_CA'), + ]) : [], + ], + 'mariadb' => [ 'driver' => 'mariadb', 'url' => env('DB_URL'), diff --git a/config/moonwell.php b/config/moonwell.php index 0eb48ba..f7a3b13 100644 --- a/config/moonwell.php +++ b/config/moonwell.php @@ -2,6 +2,7 @@ return [ 'auth_connection' => env('AZEROTHCORE_AUTH_CONNECTION', 'azerothcore_auth'), + 'characters_connection' => env('AZEROTHCORE_CHARACTERS_CONNECTION', 'azerothcore_characters'), 'registration' => [ 'gmlevel' => (int) env('AZEROTHCORE_ACCOUNT_GMLEVEL', 0), @@ -11,6 +12,17 @@ return [ 'enforce_unique_email' => (bool) env('AZEROTHCORE_ENFORCE_UNIQUE_EMAIL', false), ], + 'portal' => [ + 'admin_min_gm_level' => (int) env('MOONWELL_ADMIN_MIN_GMLEVEL', 3), + 'access_comment' => env('MOONWELL_ADMIN_ACCESS_COMMENT', 'updated via moonwell-web admin'), + ], + + 'client' => [ + 'disk' => env('MOONWELL_CLIENT_DISK', 's3'), + 'object_key' => env('MOONWELL_CLIENT_OBJECT_KEY', 'World of Warcraft.zip'), + 'temporary_url_minutes' => (int) env('MOONWELL_CLIENT_URL_TTL', 30), + ], + 'realm' => [ 'server_name' => env('MOONWELL_SERVER_NAME', 'Moonwell'), 'realm_name' => env('MOONWELL_REALM_NAME', 'Moonwell'), diff --git a/database/migrations/2026_03_13_000003_create_invite_codes_table.php b/database/migrations/2026_03_13_000003_create_invite_codes_table.php new file mode 100644 index 0000000..736e4b2 --- /dev/null +++ b/database/migrations/2026_03_13_000003_create_invite_codes_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('code', 32)->unique(); + $table->unsignedInteger('created_by_account_id')->nullable()->index(); + $table->string('created_by_username', 32)->nullable(); + $table->unsignedInteger('redeemed_by_account_id')->nullable()->index(); + $table->string('redeemed_by_username', 32)->nullable(); + $table->timestamp('redeemed_at')->nullable()->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('invite_codes'); + } +}; diff --git a/public/site.css b/public/site.css index eb79f3d..1034ef9 100644 --- a/public/site.css +++ b/public/site.css @@ -134,6 +134,19 @@ pre { color: var(--text); } +.topbar-actions { + display: inline-flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.topbar-user { + color: var(--muted); + font-size: 0.9rem; +} + .hero { display: grid; grid-template-columns: 1.25fr 0.85fr; @@ -237,7 +250,10 @@ pre { .highlight-grid, .pillar-grid, .adventure-grid, -.steps-grid { +.steps-grid, +.portal-grid, +.character-grid, +.account-list { display: grid; gap: 18px; } @@ -437,6 +453,10 @@ pre { gap: 20px; } +.portal-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .feature-card { padding: 30px; } @@ -487,6 +507,144 @@ pre { word-break: break-word; } +.stats-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin: 18px 0 0; +} + +.stats-list--compact { + margin-top: 20px; +} + +.stats-list dt, +.stats-list dd { + margin: 0; +} + +.stats-list dt { + color: var(--muted); + font-size: 0.85rem; + margin-bottom: 6px; +} + +.stats-list dd { + font-size: 1rem; + font-weight: 700; +} + +.action-stack, +.account-list { + display: grid; + gap: 16px; +} + +.muted-note { + color: var(--muted); + font-size: 0.92rem; +} + +.character-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.character-card { + position: relative; + padding: 24px; + border: 1px solid var(--line); + border-radius: 24px; + background: rgba(255, 255, 255, 0.03); + box-shadow: var(--shadow); +} + +.character-card__top, +.account-card__header, +.character-row, +.search-form, +.inline-form { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.character-card__top { + align-items: flex-start; +} + +.character-card h3, +.account-card h3 { + margin: 0 0 8px; +} + +.character-card p, +.account-card p, +.character-row span, +.empty-inline { + color: var(--muted); +} + +.tag, +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.tag { + padding: 8px 12px; + border: 1px solid rgba(241, 200, 121, 0.22); + border-radius: 999px; + background: rgba(241, 200, 121, 0.08); + color: var(--gold); + font-size: 0.84rem; + font-weight: 700; +} + +.empty-state { + text-align: center; +} + +.search-form { + margin-top: 18px; +} + +.search-form .form-control { + flex: 1 1 auto; +} + +.inline-form { + margin-top: 20px; + align-items: end; +} + +.inline-form .form-field { + flex: 1 1 auto; +} + +.account-card, +.character-list { + display: grid; + gap: 18px; +} + +.character-list { + margin-top: 18px; +} + +.character-row { + padding: 16px 18px; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, 0.03); +} + +.character-row strong { + display: block; + margin-bottom: 4px; +} + .footer { display: flex; align-items: center; @@ -499,14 +657,16 @@ pre { @media (max-width: 1120px) { .hero, - .section--split { + .section--split, + .portal-grid { grid-template-columns: 1fr; } .pillar-grid, .adventure-grid, .steps-grid, - .highlight-grid { + .highlight-grid, + .character-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -526,6 +686,16 @@ pre { justify-content: flex-start; } + .topbar-actions, + .search-form, + .inline-form, + .character-card__top, + .account-card__header, + .character-row { + flex-direction: column; + align-items: stretch; + } + .hero { padding-top: 18px; } @@ -550,7 +720,9 @@ pre { .adventure-grid, .steps-grid, .highlight-grid, - .server-card dl { + .server-card dl, + .stats-list, + .character-grid { grid-template-columns: 1fr; } } diff --git a/resources/views/admin/accounts/index.blade.php b/resources/views/admin/accounts/index.blade.php new file mode 100644 index 0000000..751df63 --- /dev/null +++ b/resources/views/admin/accounts/index.blade.php @@ -0,0 +1,166 @@ +@extends('layouts.portal') + +@section('portal-content') +
+
+ Инвайты +

Управление инвайт-кодами

+

Без действующего инвайт-кода регистрация нового аккаунта недоступна.

+
+ +
+
+

Создать инвайт-коды

+ +
+
+
Всего
+
{{ $inviteStats['total'] }}
+
+
+
Доступно
+
{{ $inviteStats['available'] }}
+
+
+
Использовано
+
{{ $inviteStats['redeemed'] }}
+
+
+ +
+ @csrf + + + + +
+
+ +
+

Последние инвайт-коды

+ + @if ($inviteCodes->isNotEmpty()) +
+ @foreach ($inviteCodes as $invite) +
+
+ {{ $invite->code }} + + Создал: {{ $invite->created_by_username ?: '—' }} + @if ($invite->created_at) + · {{ $invite->created_at->format('d.m.Y H:i') }} + @endif + +
+
+ {{ $invite->redeemed_at ? 'Использован' : 'Свободен' }} + + {{ $invite->redeemed_by_username ?: 'ещё не использован' }} + +
+
+ @endforeach +
+ @else +
Инвайт-коды ещё не создавались.
+ @endif +
+
+
+ +
+
+ Админка +

Аккаунты и персонажи

+

Здесь видны все игровые аккаунты, их персонажи и текущий уровень доступа.

+
+ +
+ + + @if ($search !== '') + Сбросить + @endif +
+
+ +
+ +
+@endsection diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..52b6a69 --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,61 @@ +@extends('layouts.portal') + +@section('portal-content') +
+
+ Личный кабинет +

Вход в игровой аккаунт

+

+ Используй логин и пароль от игрового аккаунта. После входа будут доступны персонажи, + смена пароля и ссылка на клиент игры. +

+ +
    +
  • Просмотр всех персонажей аккаунта
  • +
  • Смена пароля без ручных команд
  • +
  • Быстрый переход к клиенту игры
  • +
+
+ + +
+@endsection diff --git a/resources/views/cabinet/index.blade.php b/resources/views/cabinet/index.blade.php new file mode 100644 index 0000000..2692030 --- /dev/null +++ b/resources/views/cabinet/index.blade.php @@ -0,0 +1,152 @@ +@extends('layouts.portal') + +@section('portal-content') +
+
+ Личный кабинет +

{{ $user->username }}

+

Здесь можно посмотреть персонажей, сменить пароль и получить ссылку на клиент игры.

+
+ +
+
+

Данные аккаунта

+ +
+
+
Логин
+
{{ $user->username }}
+
+
+
Email
+
{{ $user->email !== '' ? $user->email : '—' }}
+
+
+
Уровень доступа
+
{{ $user->accessLabel() }}
+
+
+
Регистрация
+
{{ $user->joinedAtLabel() }}
+
+
+
Последний вход
+
{{ $user->lastLoginLabel() }}
+
+
+
Статус
+
{{ $user->locked ? 'Заблокирован' : 'Активен' }}
+
+
+ + @if ($user->canAccessAdminPanel()) + Перейти в админку + @endif +
+ +
+

Клиент игры

+

Ссылка формируется при нажатии. Файл выдаётся напрямую из хранилища.

+ +
+ Получить ссылку на клиент + Файл: {{ $clientFilename }} +
+
+
+
+ +
+
+ Безопасность +

Сменить пароль

+

После обновления новый пароль сразу начнёт действовать для входа в игру и кабинет.

+ +
+ @csrf + + + + + + + + +
+
+ +
+ Персонажи +

Краткая сводка

+

Всего персонажей: {{ $characters->count() }}. Ниже показан полный список по этому аккаунту.

+ + @if ($characters->isNotEmpty()) +
+ @foreach ($characters as $character) + {{ $character['name'] }} · {{ $character['class'] }} · {{ $character['level'] }} + @endforeach +
+ @else +
Персонажей пока нет.
+ @endif +
+
+ +
+
+ Мои персонажи +

Все персонажи аккаунта

+

Здесь отображаются игровые персонажи, связанные с текущим аккаунтом.

+
+ +
+ @forelse ($characters as $character) +
+
+
+

{{ $character['name'] }}

+

{{ $character['race'] }} · {{ $character['class'] }}

+
+ + {{ $character['online'] ? 'Онлайн' : 'Оффлайн' }} +
+ +
+
+
Уровень
+
{{ $character['level'] }}
+
+
+
Пол
+
{{ $character['gender'] }}
+
+
+
Золото
+
{{ $character['money'] }}
+
+
+
Наработано
+
{{ $character['played'] }}
+
+
+
+ @empty +
+

Персонажей пока нет

+

Создай персонажа в игре, и он появится здесь автоматически.

+
+ @endforelse +
+
+@endsection diff --git a/resources/views/landing.blade.php b/resources/views/landing.blade.php index cefbc2f..5da5257 100644 --- a/resources/views/landing.blade.php +++ b/resources/views/landing.blade.php @@ -17,6 +17,18 @@ Как начать Регистрация + +
+ @auth + Личный кабинет + + @if (auth()->user()->canAccessAdminPanel()) + Админка + @endif + @else + Войти + @endauth +
@@ -49,7 +61,7 @@
Регистрация

Создать игровой аккаунт

-

Укажи логин, почту и пароль — аккаунт будет готов к входу в игру сразу после регистрации.

+

Укажи инвайт-код, логин, почту и пароль — аккаунт будет готов к входу в игру сразу после регистрации.

@if (session('registration_success')) @@ -94,6 +106,22 @@ @error('email'){{ $message }}@enderror + +