админка

This commit is contained in:
2026-03-13 23:33:35 +04:00
parent bae063a4fa
commit eaedeeb52b
41 changed files with 2506 additions and 67 deletions
+18 -3
View File
@@ -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}"
+12
View File
@@ -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` — данные лендинга и обработка формы.
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Auth;
use App\Models\GameAccountUser;
use App\Services\AzerothCoreAccountService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
class AzerothCoreUserProvider implements UserProvider
{
public function __construct(private readonly AzerothCoreAccountService $accounts)
{
}
public function retrieveById($identifier): ?Authenticatable
{
if (! is_numeric($identifier)) {
return null;
}
return $this->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
{
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Exceptions;
use RuntimeException;
class InviteCodeException extends RuntimeException
{
public string $field = 'invite_code';
public static function invalid(): self
{
return new self('Укажи корректный инвайт-код.');
}
public static function alreadyUsed(): self
{
return new self('Этот инвайт-код уже использован.');
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\UpdateAccountAccessRequest;
use App\Services\AzerothCoreAccountService;
use App\Services\InviteCodeService;
use App\Support\WowData;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AccountController extends Controller
{
public function index(
Request $request,
AzerothCoreAccountService $accounts,
InviteCodeService $inviteCodes,
): View
{
$search = trim((string) $request->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', 'Уровень доступа обновлён.');
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\CreateInviteCodesRequest;
use App\Services\InviteCodeService;
use Illuminate\Http\RedirectResponse;
class InviteCodeController extends Controller
{
public function store(CreateInviteCodesRequest $request, InviteCodeService $inviteCodes): RedirectResponse
{
$created = $inviteCodes->createCodes(
(int) $request->integer('quantity'),
$request->user(),
);
return back()->with('status', sprintf('Создано инвайт-кодов: %d.', $created->count()));
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class LoginController extends Controller
{
public function create(): View
{
return view('auth.login');
}
public function store(LoginRequest $request): RedirectResponse
{
if (! Auth::attempt($request->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', 'Сеанс завершён. Можно войти снова.');
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ChangePasswordRequest;
use App\Services\AzerothCoreAccountService;
use App\Services\GameClientDownloadService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Throwable;
class CabinetController extends Controller
{
public function index(Request $request, AzerothCoreAccountService $accounts): View
{
$user = $request->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', 'Не удалось получить ссылку на клиент. Попробуй ещё раз позже.');
}
}
}
+18 -4
View File
@@ -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(),
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()
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureGameMaster
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
abort_unless(
$user && $user->canAccessAdminPanel(),
Response::HTTP_FORBIDDEN,
'Недостаточно прав для доступа к админке.',
);
return $next($request);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ChangePasswordRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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<string, string>
*/
public function messages(): array
{
return [
'current_password.required' => 'Укажи текущий пароль.',
'current_password.max' => 'Текущий пароль не должен быть длиннее 32 символов.',
'password.required' => 'Укажи новый пароль.',
'password.min' => 'Новый пароль должен содержать минимум 8 символов.',
'password.max' => 'Новый пароль не должен быть длиннее 32 символов.',
'password.confirmed' => 'Подтверждение нового пароля не совпадает.',
'password.regex' => 'Новый пароль должен содержать хотя бы одну букву и одну цифру.',
];
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CreateInviteCodesRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'quantity' => ['required', 'integer', 'min:1', 'max:50'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'quantity.required' => 'Укажи количество инвайт-кодов.',
'quantity.integer' => 'Количество должно быть числом.',
'quantity.min' => 'Нужно создать минимум один инвайт-код.',
'quantity.max' => 'За один раз можно создать не больше 50 инвайт-кодов.',
];
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class LoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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<string, string>
*/
public function messages(): array
{
return [
'username.required' => 'Укажи логин аккаунта.',
'username.min' => 'Логин должен содержать минимум 3 символа.',
'username.max' => 'Логин не должен быть длиннее 32 символов.',
'username.regex' => 'Логин может содержать только латинские буквы и цифры.',
'password.required' => 'Укажи пароль.',
'password.min' => 'Пароль должен содержать минимум 3 символа.',
'password.max' => 'Пароль не должен быть длиннее 32 символов.',
];
}
}
@@ -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' => 'правила',
];
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateAccountAccessRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'gmlevel' => ['required', 'integer', Rule::in(array_keys(\App\Support\WowData::securityLevels()))],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'gmlevel.required' => 'Укажи уровень доступа.',
'gmlevel.integer' => 'Уровень доступа должен быть числом.',
'gmlevel.in' => 'Выбран недопустимый уровень доступа.',
];
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Models;
use App\Support\WowData;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Support\Carbon;
class GameAccountUser implements AuthenticatableContract
{
public function __construct(
public readonly int $id,
public readonly string $username,
public readonly string $email,
public readonly string $regMail,
public readonly int $gmLevel,
public readonly bool $locked,
public readonly ?string $lastLoginAt,
public readonly ?string $joinedAt,
public readonly string $saltHex,
public readonly string $verifierHex,
) {
}
public function getAuthIdentifierName(): string
{
return 'id';
}
public function getAuthIdentifier(): int
{
return $this->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') : '—';
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class InviteCode extends Model
{
protected $fillable = [
'code',
'created_by_account_id',
'created_by_username',
'redeemed_by_account_id',
'redeemed_by_username',
'redeemed_at',
];
protected function casts(): array
{
return [
'redeemed_at' => 'datetime',
];
}
}
+5 -1
View File
@@ -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);
});
}
}
+7 -34
View File
@@ -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';
+234
View File
@@ -0,0 +1,234 @@
<?php
namespace App\Services;
use App\Models\GameAccountUser;
use App\Support\WowData;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class AzerothCoreAccountService
{
public function __construct(private readonly AzerothCoreSrpService $srp)
{
}
public function findUserById(int $accountId): ?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('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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, int> $accountIds
* @return \Illuminate\Support\Collection<int, \Illuminate\Support\Collection<int, array<string, mixed>>>
*/
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<int, int> $accountIds
* @return array<int, int>
*/
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'));
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Services;
use Brick\Math\BigInteger;
class AzerothCoreSrpService
{
private const string MODULUS_HEX = '894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7';
private const int GENERATOR = 7;
private const int BYTE_LENGTH = 32;
public function normalizeUsername(string $username): string
{
return strtoupper(trim($username));
}
public function normalizeEmail(string $email): string
{
return strtoupper(trim($email));
}
public function normalizePassword(string $password): string
{
return strtoupper($password);
}
/**
* @return array{0: string, 1: string}
*/
public function makeRegistrationData(string $username, string $password): array
{
$saltHex = strtoupper(bin2hex(random_bytes(self::BYTE_LENGTH)));
return [$saltHex, $this->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),
);
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Storage;
use Throwable;
class GameClientDownloadService
{
public function filename(): string
{
return basename($this->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');
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Services;
use App\Exceptions\InviteCodeException;
use App\Models\GameAccountUser;
use App\Models\InviteCode;
use Closure;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class InviteCodeService
{
/**
* @return \Illuminate\Support\Collection<int, \App\Models\InviteCode>
*/
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<int, \App\Models\InviteCode>
*/
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));
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Support;
class WowData
{
/**
* @return array<int, string>
*/
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);
}
}
+3 -1
View File
@@ -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 {
//
+4
View File
@@ -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,
Generated
+347 -2
View File
@@ -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"
}
+6 -14
View File
@@ -1,7 +1,5 @@
<?php
use App\Models\User;
return [
/*
@@ -17,7 +15,7 @@ return [
'defaults' => [
'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,
+20
View File
@@ -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'),
+12
View File
@@ -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'),
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('invite_codes', function (Blueprint $table) {
$table->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');
}
};
+176 -4
View File
@@ -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;
}
}
@@ -0,0 +1,166 @@
@extends('layouts.portal')
@section('portal-content')
<section class="section">
<div class="section-heading">
<span class="eyebrow">Инвайты</span>
<h2>Управление инвайт-кодами</h2>
<p>Без действующего инвайт-кода регистрация нового аккаунта недоступна.</p>
</div>
<div class="portal-grid">
<article class="content-card">
<h3>Создать инвайт-коды</h3>
<dl class="stats-list stats-list--compact">
<div>
<dt>Всего</dt>
<dd>{{ $inviteStats['total'] }}</dd>
</div>
<div>
<dt>Доступно</dt>
<dd>{{ $inviteStats['available'] }}</dd>
</div>
<div>
<dt>Использовано</dt>
<dd>{{ $inviteStats['redeemed'] }}</dd>
</div>
</dl>
<form class="inline-form" method="POST" action="{{ route('admin.invite-codes.store') }}">
@csrf
<label class="form-field">
<span>Количество</span>
<input class="form-control @error('quantity') is-invalid @enderror" type="number" min="1" max="50" name="quantity" value="{{ old('quantity', 1) }}" required>
@error('quantity')<span class="form-error">{{ $message }}</span>@enderror
</label>
<button class="button button--gold" type="submit">Создать коды</button>
</form>
</article>
<article class="content-card">
<h3>Последние инвайт-коды</h3>
@if ($inviteCodes->isNotEmpty())
<div class="character-list">
@foreach ($inviteCodes as $invite)
<div class="character-row">
<div>
<strong>{{ $invite->code }}</strong>
<span>
Создал: {{ $invite->created_by_username ?: '—' }}
@if ($invite->created_at)
· {{ $invite->created_at->format('d.m.Y H:i') }}
@endif
</span>
</div>
<div>
<strong>{{ $invite->redeemed_at ? 'Использован' : 'Свободен' }}</strong>
<span>
{{ $invite->redeemed_by_username ?: 'ещё не использован' }}
</span>
</div>
</div>
@endforeach
</div>
@else
<div class="empty-inline">Инвайт-коды ещё не создавались.</div>
@endif
</article>
</div>
</section>
<section class="section">
<div class="section-heading">
<span class="eyebrow">Админка</span>
<h2>Аккаунты и персонажи</h2>
<p>Здесь видны все игровые аккаунты, их персонажи и текущий уровень доступа.</p>
</div>
<form class="search-form" method="GET" action="{{ route('admin.accounts.index') }}">
<input class="form-control" type="search" name="search" value="{{ $search }}" placeholder="Поиск по логину или email">
<button class="button button--gold" type="submit">Найти</button>
@if ($search !== '')
<a class="button button--ghost" href="{{ route('admin.accounts.index') }}">Сбросить</a>
@endif
</form>
</section>
<section class="section">
<div class="account-list">
@forelse ($accounts as $account)
<article class="content-card account-card">
<div class="account-card__header">
<div>
<h3>{{ $account['username'] }}</h3>
<p>ID #{{ $account['id'] }} · {{ $account['email'] !== '' ? $account['email'] : 'email не указан' }}</p>
</div>
<span class="tag">{{ $account['access_label'] }}</span>
</div>
<dl class="stats-list stats-list--compact">
<div>
<dt>Регистрация</dt>
<dd>{{ $account['joined_at'] }}</dd>
</div>
<div>
<dt>Последний вход</dt>
<dd>{{ $account['last_login_at'] }}</dd>
</div>
<div>
<dt>Статус</dt>
<dd>{{ $account['locked'] ? 'Заблокирован' : 'Активен' }}</dd>
</div>
<div>
<dt>Персонажей</dt>
<dd>{{ $account['characters']->count() }}</dd>
</div>
</dl>
<form class="inline-form" method="POST" action="{{ route('admin.accounts.access.update', $account['id']) }}">
@csrf
@method('PATCH')
<label class="form-field">
<span>Уровень доступа</span>
<select class="form-control @error('gmlevel') is-invalid @enderror" name="gmlevel">
@foreach ($accessLevels as $level => $label)
<option value="{{ $level }}" @selected($account['gm_level'] === $level)>{{ $label }}</option>
@endforeach
</select>
</label>
<button class="button button--gold" type="submit">Сохранить</button>
</form>
@if ($account['characters']->isNotEmpty())
<div class="character-list">
@foreach ($account['characters'] as $character)
<div class="character-row">
<div>
<strong>{{ $character['name'] }}</strong>
<span>{{ $character['race'] }} · {{ $character['class'] }}</span>
</div>
<div>
<strong>{{ $character['level'] }} ур.</strong>
<span>{{ $character['online'] ? 'Онлайн' : 'Оффлайн' }}</span>
</div>
</div>
@endforeach
</div>
@else
<div class="empty-inline">На аккаунте пока нет персонажей.</div>
@endif
</article>
@empty
<article class="content-card empty-state">
<h3>Аккаунты не найдены</h3>
<p>Измени поисковый запрос или попробуй позже.</p>
</article>
@endforelse
</div>
</section>
@endsection
+61
View File
@@ -0,0 +1,61 @@
@extends('layouts.portal')
@section('portal-content')
<section class="section section--split">
<article class="content-card">
<span class="eyebrow">Личный кабинет</span>
<h2>Вход в игровой аккаунт</h2>
<p>
Используй логин и пароль от игрового аккаунта. После входа будут доступны персонажи,
смена пароля и ссылка на клиент игры.
</p>
<ul class="feature-points">
<li>Просмотр всех персонажей аккаунта</li>
<li>Смена пароля без ручных команд</li>
<li>Быстрый переход к клиенту игры</li>
</ul>
</article>
<aside class="hero-panel">
<div class="panel-header">
<span class="eyebrow">Авторизация</span>
<h2>Войти</h2>
<p>Вход выполняется по игровым учётным данным.</p>
</div>
<form class="register-form" method="POST" action="{{ route('login.store') }}">
@csrf
<label class="form-field">
<span>Логин</span>
<input
class="form-control @error('username') is-invalid @enderror"
type="text"
name="username"
value="{{ old('username') }}"
autocomplete="username"
maxlength="32"
required
>
@error('username')<span class="form-error">{{ $message }}</span>@enderror
</label>
<label class="form-field">
<span>Пароль</span>
<input
class="form-control @error('password') is-invalid @enderror"
type="password"
name="password"
autocomplete="current-password"
maxlength="32"
required
>
@error('password')<span class="form-error">{{ $message }}</span>@enderror
</label>
<button class="button button--gold button--full" type="submit">Войти в кабинет</button>
</form>
</aside>
</section>
@endsection
+152
View File
@@ -0,0 +1,152 @@
@extends('layouts.portal')
@section('portal-content')
<section class="section">
<div class="section-heading">
<span class="eyebrow">Личный кабинет</span>
<h2>{{ $user->username }}</h2>
<p>Здесь можно посмотреть персонажей, сменить пароль и получить ссылку на клиент игры.</p>
</div>
<div class="portal-grid">
<article class="content-card">
<h3>Данные аккаунта</h3>
<dl class="stats-list">
<div>
<dt>Логин</dt>
<dd>{{ $user->username }}</dd>
</div>
<div>
<dt>Email</dt>
<dd>{{ $user->email !== '' ? $user->email : '—' }}</dd>
</div>
<div>
<dt>Уровень доступа</dt>
<dd>{{ $user->accessLabel() }}</dd>
</div>
<div>
<dt>Регистрация</dt>
<dd>{{ $user->joinedAtLabel() }}</dd>
</div>
<div>
<dt>Последний вход</dt>
<dd>{{ $user->lastLoginLabel() }}</dd>
</div>
<div>
<dt>Статус</dt>
<dd>{{ $user->locked ? 'Заблокирован' : 'Активен' }}</dd>
</div>
</dl>
@if ($user->canAccessAdminPanel())
<a class="button button--ghost" href="{{ route('admin.accounts.index') }}">Перейти в админку</a>
@endif
</article>
<article class="content-card">
<h3>Клиент игры</h3>
<p>Ссылка формируется при нажатии. Файл выдаётся напрямую из хранилища.</p>
<div class="action-stack">
<a class="button button--gold" href="{{ route('cabinet.client') }}">Получить ссылку на клиент</a>
<span class="muted-note">Файл: {{ $clientFilename }}</span>
</div>
</article>
</div>
</section>
<section class="section section--split">
<article class="content-card">
<span class="eyebrow">Безопасность</span>
<h2>Сменить пароль</h2>
<p>После обновления новый пароль сразу начнёт действовать для входа в игру и кабинет.</p>
<form class="register-form" method="POST" action="{{ route('cabinet.password.update') }}">
@csrf
<label class="form-field">
<span>Текущий пароль</span>
<input class="form-control @error('current_password') is-invalid @enderror" type="password" name="current_password" maxlength="32" required>
@error('current_password')<span class="form-error">{{ $message }}</span>@enderror
</label>
<label class="form-field">
<span>Новый пароль</span>
<input class="form-control @error('password') is-invalid @enderror" type="password" name="password" maxlength="32" required>
</label>
<label class="form-field">
<span>Подтверждение нового пароля</span>
<input class="form-control @error('password') is-invalid @enderror" type="password" name="password_confirmation" maxlength="32" required>
@error('password')<span class="form-error">{{ $message }}</span>@enderror
</label>
<button class="button button--gold button--full" type="submit">Обновить пароль</button>
</form>
</article>
<article class="content-card">
<span class="eyebrow">Персонажи</span>
<h2>Краткая сводка</h2>
<p>Всего персонажей: {{ $characters->count() }}. Ниже показан полный список по этому аккаунту.</p>
@if ($characters->isNotEmpty())
<div class="tag-list">
@foreach ($characters as $character)
<span class="tag">{{ $character['name'] }} · {{ $character['class'] }} · {{ $character['level'] }}</span>
@endforeach
</div>
@else
<div class="empty-inline">Персонажей пока нет.</div>
@endif
</article>
</section>
<section class="section">
<div class="section-heading">
<span class="eyebrow">Мои персонажи</span>
<h2>Все персонажи аккаунта</h2>
<p>Здесь отображаются игровые персонажи, связанные с текущим аккаунтом.</p>
</div>
<div class="character-grid">
@forelse ($characters as $character)
<article class="character-card">
<div class="character-card__top">
<div>
<h3>{{ $character['name'] }}</h3>
<p>{{ $character['race'] }} · {{ $character['class'] }}</p>
</div>
<span class="tag">{{ $character['online'] ? 'Онлайн' : 'Оффлайн' }}</span>
</div>
<dl class="stats-list stats-list--compact">
<div>
<dt>Уровень</dt>
<dd>{{ $character['level'] }}</dd>
</div>
<div>
<dt>Пол</dt>
<dd>{{ $character['gender'] }}</dd>
</div>
<div>
<dt>Золото</dt>
<dd>{{ $character['money'] }}</dd>
</div>
<div>
<dt>Наработано</dt>
<dd>{{ $character['played'] }}</dd>
</div>
</dl>
</article>
@empty
<article class="content-card empty-state">
<h3>Персонажей пока нет</h3>
<p>Создай персонажа в игре, и он появится здесь автоматически.</p>
</article>
@endforelse
</div>
</section>
@endsection
+29 -1
View File
@@ -17,6 +17,18 @@
<a href="#start">Как начать</a>
<a href="#register">Регистрация</a>
</nav>
<div class="topbar-actions">
@auth
<a class="button button--ghost" href="{{ route('cabinet.index') }}">Личный кабинет</a>
@if (auth()->user()->canAccessAdminPanel())
<a class="button button--ghost" href="{{ route('admin.accounts.index') }}">Админка</a>
@endif
@else
<a class="button button--ghost" href="{{ route('login') }}">Войти</a>
@endauth
</div>
</header>
<main>
@@ -49,7 +61,7 @@
<div class="panel-header">
<span class="eyebrow">Регистрация</span>
<h2>Создать игровой аккаунт</h2>
<p>Укажи логин, почту и пароль аккаунт будет готов к входу в игру сразу после регистрации.</p>
<p>Укажи инвайт-код, логин, почту и пароль аккаунт будет готов к входу в игру сразу после регистрации.</p>
</div>
@if (session('registration_success'))
@@ -94,6 +106,22 @@
@error('email')<span class="form-error">{{ $message }}</span>@enderror
</label>
<label class="form-field">
<span>Инвайт-код</span>
<input
class="form-control @error('invite_code') is-invalid @enderror"
type="text"
name="invite_code"
value="{{ old('invite_code') }}"
autocomplete="off"
placeholder="ABCD-EFGH-IJKL"
maxlength="32"
required
>
<small>Регистрация доступна только по действующему инвайт-коду.</small>
@error('invite_code')<span class="form-error">{{ $message }}</span>@enderror
</label>
<div class="form-row">
<label class="form-field">
<span>Пароль</span>
+52
View File
@@ -0,0 +1,52 @@
@extends('layouts.app')
@section('content')
@php($portalUser = auth()->user())
<div class="page-shell">
<header class="topbar">
<a class="brand" href="{{ route('landing.index') }}">
<span class="brand-mark"></span>
<span>
<strong>{{ config('moonwell.realm.server_name') }}</strong>
<small>{{ config('moonwell.realm.tagline') }}</small>
</span>
</a>
<nav class="topnav">
<a href="{{ route('landing.index') }}">Главная</a>
@if ($portalUser)
<a href="{{ route('cabinet.index') }}">Личный кабинет</a>
@if ($portalUser->canAccessAdminPanel())
<a href="{{ route('admin.accounts.index') }}">Админка</a>
@endif
@endif
</nav>
<div class="topbar-actions">
@if ($portalUser)
<span class="topbar-user">{{ $portalUser->username }} · {{ $portalUser->accessLabel() }}</span>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button class="button button--ghost" type="submit">Выйти</button>
</form>
@else
<a class="button button--ghost" href="{{ route('login') }}">Войти</a>
@endif
</div>
</header>
@if (session('status'))
<div class="alert alert--success">{{ session('status') }}</div>
@endif
@if (session('error'))
<div class="alert alert--error">{{ session('error') }}</div>
@endif
<main>
@yield('portal-content')
</main>
</div>
@endsection
+27
View File
@@ -1,5 +1,9 @@
<?php
use App\Http\Controllers\Admin\AccountController;
use App\Http\Controllers\Admin\InviteCodeController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\CabinetController;
use App\Http\Controllers\LandingController;
use Illuminate\Support\Facades\Route;
@@ -7,3 +11,26 @@ Route::get('/', [LandingController::class, 'index'])->name('landing.index');
Route::post('/register', [LandingController::class, 'register'])
->middleware('throttle:8,1')
->name('game-account.store');
Route::middleware('guest')->group(function (): void {
Route::get('/login', [LoginController::class, 'create'])->name('login');
Route::post('/login', [LoginController::class, 'store'])
->middleware('throttle:10,1')
->name('login.store');
});
Route::middleware('auth')->group(function (): void {
Route::post('/logout', [LoginController::class, 'destroy'])->name('logout');
Route::get('/cabinet', [CabinetController::class, 'index'])->name('cabinet.index');
Route::post('/cabinet/password', [CabinetController::class, 'updatePassword'])->name('cabinet.password.update');
Route::get('/cabinet/client', [CabinetController::class, 'client'])->name('cabinet.client');
});
Route::middleware(['auth', 'gamemaster'])
->prefix('admin')
->name('admin.')
->group(function (): void {
Route::get('/accounts', [AccountController::class, 'index'])->name('accounts.index');
Route::patch('/accounts/{accountId}/access', [AccountController::class, 'updateAccess'])->name('accounts.access.update');
Route::post('/invite-codes', [InviteCodeController::class, 'store'])->name('invite-codes.store');
});
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace Tests\Feature;
use App\Models\GameAccountUser;
use App\Services\AzerothCoreAccountService;
use App\Services\InviteCodeService;
use Illuminate\Support\Collection;
use Mockery\MockInterface;
use Tests\TestCase;
class AdminAccountsFeatureTest extends TestCase
{
public function test_guest_is_redirected_to_login_for_admin(): void
{
$response = $this->get(route('admin.accounts.index'));
$response->assertRedirect(route('login'));
}
public function test_regular_player_cannot_open_admin_accounts_page(): void
{
$response = $this->actingAs($this->makeUser(0))->get(route('admin.accounts.index'));
$response->assertForbidden();
}
public function test_admin_can_open_accounts_page(): void
{
$this->mock(AzerothCoreAccountService::class, function (MockInterface $mock): void {
$mock->shouldReceive('listAccounts')
->once()
->with(null)
->andReturn(new Collection([
[
'id' => 1,
'username' => 'ADMIN',
'email' => 'ADMIN@EXAMPLE.COM',
'reg_mail' => 'ADMIN@EXAMPLE.COM',
'gm_level' => 3,
'access_label' => 'Администратор',
'locked' => false,
'online' => false,
'joined_at' => '12.03.2026 12:00',
'last_login_at' => '13.03.2026 18:00',
'characters' => new Collection([
[
'name' => 'Stormmage',
'race' => 'Человек',
'class' => 'Маг',
'level' => 80,
'online' => false,
],
]),
],
]));
});
$this->mock(InviteCodeService::class, function (MockInterface $mock): void {
$mock->shouldReceive('latest')
->once()
->andReturn(new Collection());
$mock->shouldReceive('stats')
->once()
->andReturn(['total' => 0, 'available' => 0, 'redeemed' => 0]);
});
$response = $this->actingAs($this->makeUser(3))->get(route('admin.accounts.index'));
$response->assertOk();
$response->assertSee('Stormmage');
$response->assertSee('Аккаунты и персонажи');
$response->assertSee('Управление инвайт-кодами');
}
public function test_admin_can_update_access_level(): void
{
$this->mock(AzerothCoreAccountService::class, function (MockInterface $mock): void {
$mock->shouldReceive('findUserById')
->once()
->with(12)
->andReturn($this->makeUser(0));
$mock->shouldReceive('updateAccessLevel')
->once()
->with(12, 2);
});
$response = $this->from(route('admin.accounts.index'))
->actingAs($this->makeUser(3))
->patch(route('admin.accounts.access.update', 12), [
'gmlevel' => 2,
]);
$response->assertRedirect(route('admin.accounts.index'));
$response->assertSessionHas('status', 'Уровень доступа обновлён.');
}
public function test_admin_can_create_invite_codes(): void
{
$admin = $this->makeUser(3);
$this->mock(InviteCodeService::class, function (MockInterface $mock) use ($admin): void {
$mock->shouldReceive('createCodes')
->once()
->with(3, $admin)
->andReturn(new Collection([1, 2, 3]));
});
$response = $this->from(route('admin.accounts.index'))
->actingAs($admin)
->post(route('admin.invite-codes.store'), [
'quantity' => 3,
]);
$response->assertRedirect(route('admin.accounts.index'));
$response->assertSessionHas('status', 'Создано инвайт-кодов: 3.');
}
private function makeUser(int $gmLevel): GameAccountUser
{
return new GameAccountUser(
id: 1,
username: 'ADMIN',
email: 'ADMIN@EXAMPLE.COM',
regMail: 'ADMIN@EXAMPLE.COM',
gmLevel: $gmLevel,
locked: false,
lastLoginAt: '2026-03-13 18:00:00',
joinedAt: '2026-03-12 12:00:00',
saltHex: str_repeat('AA', 32),
verifierHex: str_repeat('BB', 32),
);
}
}
+128
View File
@@ -0,0 +1,128 @@
<?php
namespace Tests\Feature;
use App\Models\GameAccountUser;
use App\Services\AzerothCoreAccountService;
use App\Services\GameClientDownloadService;
use Illuminate\Support\Collection;
use Mockery\MockInterface;
use Tests\TestCase;
class CabinetFeatureTest extends TestCase
{
public function test_guest_is_redirected_to_login_for_cabinet(): void
{
$response = $this->get(route('cabinet.index'));
$response->assertRedirect(route('login'));
}
public function test_authenticated_user_can_open_cabinet(): void
{
$user = $this->makeUser();
$this->mock(AzerothCoreAccountService::class, function (MockInterface $mock) use ($user): void {
$mock->shouldReceive('charactersForAccount')
->once()
->with($user->id)
->andReturn(new Collection([
[
'name' => 'Arthion',
'race' => 'Человек',
'class' => 'Паладин',
'gender' => 'Мужской',
'level' => 80,
'money' => '125з 0с 0м',
'played' => '4д 6ч',
'online' => false,
],
]));
});
$response = $this->actingAs($user)->get(route('cabinet.index'));
$response->assertOk();
$response->assertSee('Arthion');
$response->assertSee('Получить ссылку на клиент');
}
public function test_authenticated_user_can_change_password(): void
{
$user = $this->makeUser();
$this->mock(AzerothCoreAccountService::class, function (MockInterface $mock) use ($user): void {
$mock->shouldReceive('validatePassword')
->once()
->with($user, 'OldPass123')
->andReturn(true);
$mock->shouldReceive('updatePassword')
->once()
->with($user, 'NewPass123');
});
$response = $this->actingAs($user)->post(route('cabinet.password.update'), [
'current_password' => 'OldPass123',
'password' => 'NewPass123',
'password_confirmation' => 'NewPass123',
]);
$response->assertRedirect();
$response->assertSessionHas('status', 'Пароль успешно обновлён.');
}
public function test_password_change_rejects_invalid_current_password(): void
{
$user = $this->makeUser();
$this->mock(AzerothCoreAccountService::class, function (MockInterface $mock) use ($user): void {
$mock->shouldReceive('validatePassword')
->once()
->with($user, 'WrongPass123')
->andReturn(false);
$mock->shouldNotReceive('updatePassword');
});
$response = $this->from(route('cabinet.index'))->actingAs($user)->post(route('cabinet.password.update'), [
'current_password' => 'WrongPass123',
'password' => 'NewPass123',
'password_confirmation' => 'NewPass123',
]);
$response->assertRedirect(route('cabinet.index'));
$response->assertSessionHasErrors(['current_password']);
}
public function test_authenticated_user_can_request_client_link(): void
{
$user = $this->makeUser();
$this->mock(GameClientDownloadService::class, function (MockInterface $mock): void {
$mock->shouldReceive('temporaryUrl')
->once()
->andReturn('https://example.com/client.zip');
});
$response = $this->actingAs($user)->get(route('cabinet.client'));
$response->assertRedirect('https://example.com/client.zip');
}
private function makeUser(int $gmLevel = 0): GameAccountUser
{
return new GameAccountUser(
id: 7,
username: 'PLAYERONE',
email: 'PLAYER@EXAMPLE.COM',
regMail: 'PLAYER@EXAMPLE.COM',
gmLevel: $gmLevel,
locked: false,
lastLoginAt: '2026-03-13 18:00:00',
joinedAt: '2026-03-12 12:00:00',
saltHex: str_repeat('AA', 32),
verifierHex: str_repeat('BB', 32),
);
}
}
+41 -1
View File
@@ -3,7 +3,10 @@
namespace Tests\Feature;
use App\Exceptions\DuplicateGameAccountException;
use App\Exceptions\InviteCodeException;
use App\Services\AzerothCoreAccountRegistrar;
use App\Services\InviteCodeService;
use Closure;
use Mockery\MockInterface;
use Tests\TestCase;
@@ -11,6 +14,13 @@ class GameAccountRegistrationTest extends TestCase
{
public function test_registration_form_submits_successfully(): void
{
$this->mock(InviteCodeService::class, function (MockInterface $mock): void {
$mock->shouldReceive('redeemForRegistration')
->once()
->with('INVITE-1234', 'playerone', \Mockery::type(Closure::class))
->andReturnUsing(fn (string $code, string $username, Closure $callback): array => $callback());
});
$this->mock(AzerothCoreAccountRegistrar::class, function (MockInterface $mock): void {
$mock->shouldReceive('register')
->once()
@@ -21,6 +31,7 @@ class GameAccountRegistrationTest extends TestCase
$response = $this->from(route('landing.index'))->post(route('game-account.store'), [
'username' => 'playerone',
'email' => 'player@example.com',
'invite_code' => 'INVITE-1234',
'password' => 'Pass1234',
'password_confirmation' => 'Pass1234',
'terms' => '1',
@@ -35,17 +46,24 @@ class GameAccountRegistrationTest extends TestCase
$response = $this->from(route('landing.index'))->post(route('game-account.store'), [
'username' => 'игрок!',
'email' => 'not-an-email',
'invite_code' => '',
'password' => '123',
'password_confirmation' => '456',
'terms' => '',
]);
$response->assertRedirect(route('landing.index'));
$response->assertSessionHasErrors(['username', 'email', 'password', 'terms']);
$response->assertSessionHasErrors(['username', 'email', 'invite_code', 'password', 'terms']);
}
public function test_duplicate_username_is_reported_back_to_the_form(): void
{
$this->mock(InviteCodeService::class, function (MockInterface $mock): void {
$mock->shouldReceive('redeemForRegistration')
->once()
->andReturnUsing(fn (string $code, string $username, Closure $callback): array => $callback());
});
$this->mock(AzerothCoreAccountRegistrar::class, function (MockInterface $mock): void {
$mock->shouldReceive('register')
->once()
@@ -55,6 +73,7 @@ class GameAccountRegistrationTest extends TestCase
$response = $this->from(route('landing.index'))->post(route('game-account.store'), [
'username' => 'playerone',
'email' => 'player@example.com',
'invite_code' => 'INVITE-1234',
'password' => 'Pass1234',
'password_confirmation' => 'Pass1234',
'terms' => '1',
@@ -63,4 +82,25 @@ class GameAccountRegistrationTest extends TestCase
$response->assertRedirect(route('landing.index'));
$response->assertSessionHasErrors(['username']);
}
public function test_invalid_invite_code_is_reported_back_to_the_form(): void
{
$this->mock(InviteCodeService::class, function (MockInterface $mock): void {
$mock->shouldReceive('redeemForRegistration')
->once()
->andThrow(InviteCodeException::invalid());
});
$response = $this->from(route('landing.index'))->post(route('game-account.store'), [
'username' => 'playerone',
'email' => 'player@example.com',
'invite_code' => 'WRONG-CODE',
'password' => 'Pass1234',
'password_confirmation' => 'Pass1234',
'terms' => '1',
]);
$response->assertRedirect(route('landing.index'));
$response->assertSessionHasErrors(['invite_code']);
}
}