first commit
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class DuplicateGameAccountException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $field,
|
||||
string $message,
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
|
||||
public static function forUsername(): self
|
||||
{
|
||||
return new self('username', 'Такой игровой аккаунт уже существует.');
|
||||
}
|
||||
|
||||
public static function forEmail(): self
|
||||
{
|
||||
return new self('email', 'На этот email уже зарегистрирован аккаунт.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exceptions\DuplicateGameAccountException;
|
||||
use App\Http\Requests\RegisterGameAccountRequest;
|
||||
use App\Services\AzerothCoreAccountRegistrar;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
use Throwable;
|
||||
|
||||
class LandingController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$realm = config('moonwell.realm');
|
||||
|
||||
return view('landing', [
|
||||
'realm' => $realm,
|
||||
'highlights' => [
|
||||
['value' => $realm['client_version'], 'label' => 'Клиент'],
|
||||
['value' => '1+', 'label' => 'Игрок в инсте'],
|
||||
['value' => 'Свой', 'label' => 'лаунчер'],
|
||||
['value' => 'Быстрый', 'label' => 'сбор добычи'],
|
||||
],
|
||||
'pillars' => [
|
||||
[
|
||||
'title' => 'Персональная прогрессия',
|
||||
'description' => 'Каждый персонаж проходит контент поэтапно: старые зоны, подземелья и рейды не теряют смысл после старта.',
|
||||
],
|
||||
[
|
||||
'title' => 'Инсты под любой состав',
|
||||
'description' => 'Автобаланс подстраивает сложность под количество игроков, поэтому данжи и рейды остаются проходимыми даже малой группой.',
|
||||
],
|
||||
[
|
||||
'title' => 'Комфорт для малого состава',
|
||||
'description' => 'Даже если вы играете соло или небольшой компанией, подземелья и другой PvE-контент остаются доступными и удобными для прохождения.',
|
||||
],
|
||||
[
|
||||
'title' => 'Меньше рутины',
|
||||
'description' => 'Базовые заклинания учатся автоматически, а лут с ближайших мобов собирается быстрее — меньше лишней беготни, больше игры.',
|
||||
],
|
||||
[
|
||||
'title' => 'Собственный лаунчер',
|
||||
'description' => 'Игрок получает удобную точку входа в проект: отдельный лаунчер помогает держать клиент и запуск под рукой.',
|
||||
],
|
||||
],
|
||||
'featureRows' => [
|
||||
[
|
||||
'eyebrow' => 'Прогрессия персонажа',
|
||||
'title' => 'Контент открывается по мере развития героя',
|
||||
'description' => 'Система персональной прогрессии делает путь важнее спешки к эндгейму: каждый персонаж идёт по своему этапу и открывает контент постепенно.',
|
||||
'points' => ['Старый контент остаётся актуальным', 'Разблокировки идут по мере продвижения', 'Групповая игра учитывает этап прогрессии'],
|
||||
],
|
||||
[
|
||||
'eyebrow' => 'Комфортный PvE',
|
||||
'title' => 'Подземелья и рейды не зависят от полного рейдового состава',
|
||||
'description' => 'Сервер адаптирован для соло-игроков и небольших компаний, поэтому начинать и проходить PvE-контент заметно проще.',
|
||||
'points' => ['Сложность масштабируется под размер группы', 'Комфортно играть соло и в малом составе', 'Прохождение не требует полного рейдового набора'],
|
||||
],
|
||||
],
|
||||
'adventures' => [
|
||||
[
|
||||
'title' => 'Быстрый сбор добычи',
|
||||
'description' => 'Добыча с ближайших противников собирается заметно быстрее — особенно удобно во время фарма и массовых боёв.',
|
||||
],
|
||||
[
|
||||
'title' => 'Автообучение заклинаниям',
|
||||
'description' => 'Классовые способности, ранги заклинаний и часть связанных навыков выдаются автоматически по мере развития персонажа.',
|
||||
],
|
||||
[
|
||||
'title' => 'Кастомные игровые системы',
|
||||
'description' => 'На сервере уже есть база для развития особых режимов, сезонных активностей и дополнительных игровых сервисов.',
|
||||
],
|
||||
[
|
||||
'title' => 'Простая регистрация',
|
||||
'description' => 'Игровой аккаунт создаётся сразу на сайте, без отдельной панели и ручных команд на сервере.',
|
||||
],
|
||||
],
|
||||
'steps' => [
|
||||
['title' => 'Создай аккаунт', 'description' => 'Заполни форму регистрации и получи логин для входа в игровой мир.'],
|
||||
['title' => 'Скачай лаунчер', 'description' => 'Установи собственный лаунчер проекта и подготовь клиент к запуску.'],
|
||||
['title' => 'Входи в игру', 'description' => sprintf('Запусти клиент, при необходимости используй realmlist %s и отправляйся в Азерот.', $realm['realmlist'])],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function register(RegisterGameAccountRequest $request, AzerothCoreAccountRegistrar $registrar): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$result = $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']));
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return back()
|
||||
->with('registration_error', 'Не удалось создать аккаунт. Проверь настройки сервера и попробуй снова.')
|
||||
->withInput($request->safe()->only(['username', 'email']));
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('landing.index')
|
||||
->with('registration_success', sprintf('Игровой аккаунт %s успешно создан. Теперь можно входить в клиент WoW.', $result['username']));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class RegisterGameAccountRequest 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]+$/'],
|
||||
'email' => ['required', 'string', 'email:rfc', 'max:255'],
|
||||
'password' => ['required', 'string', 'min:8', 'max:32', 'confirmed', 'regex:/^(?=.*[A-Za-z])(?=.*\d).+$/'],
|
||||
'terms' => ['accepted'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'username.required' => 'Укажи логин аккаунта.',
|
||||
'username.string' => 'Логин должен быть строкой.',
|
||||
'username.min' => 'Логин должен содержать минимум 3 символа.',
|
||||
'username.max' => 'Логин не должен быть длиннее 32 символов.',
|
||||
'username.regex' => 'Логин может содержать только латинские буквы и цифры.',
|
||||
'email.required' => 'Укажи email для аккаунта.',
|
||||
'email.string' => 'Email должен быть строкой.',
|
||||
'email.email' => 'Укажи корректный email адрес.',
|
||||
'email.max' => 'Email не должен быть длиннее 255 символов.',
|
||||
'password.required' => 'Укажи пароль.',
|
||||
'password.string' => 'Пароль должен быть строкой.',
|
||||
'password.min' => 'Пароль должен содержать минимум 8 символов.',
|
||||
'password.max' => 'Пароль не должен быть длиннее 32 символов.',
|
||||
'password.confirmed' => 'Подтверждение пароля не совпадает.',
|
||||
'password.regex' => 'Пароль должен содержать хотя бы одну букву и одну цифру.',
|
||||
'terms.accepted' => 'Нужно принять правила сервера и пользовательское соглашение.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'username' => 'логин аккаунта',
|
||||
'email' => 'email',
|
||||
'password' => 'пароль',
|
||||
'terms' => 'правила',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* @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);
|
||||
|
||||
$connection = DB::connection(config('moonwell.auth_connection'));
|
||||
|
||||
return $connection->transaction(function () use (
|
||||
$connection,
|
||||
$normalizedEmail,
|
||||
$normalizedUsername,
|
||||
$saltHex,
|
||||
$verifierHex,
|
||||
): array {
|
||||
if ($this->accountExists($connection, 'username', $normalizedUsername)) {
|
||||
throw DuplicateGameAccountException::forUsername();
|
||||
}
|
||||
|
||||
if (
|
||||
config('moonwell.registration.enforce_unique_email')
|
||||
&& $normalizedEmail !== ''
|
||||
&& $this->accountExists($connection, 'email', $normalizedEmail)
|
||||
) {
|
||||
throw DuplicateGameAccountException::forEmail();
|
||||
}
|
||||
|
||||
try {
|
||||
$connection->insert(
|
||||
'INSERT INTO account (username, salt, verifier, expansion, reg_mail, email, joindate) VALUES (?, UNHEX(?), UNHEX(?), ?, ?, ?, ?)',
|
||||
[
|
||||
$normalizedUsername,
|
||||
$saltHex,
|
||||
$verifierHex,
|
||||
config('moonwell.registration.expansion'),
|
||||
$normalizedEmail,
|
||||
$normalizedEmail,
|
||||
now()->toDateTimeString(),
|
||||
],
|
||||
);
|
||||
} catch (QueryException $exception) {
|
||||
if ($this->isDuplicateKeyException($exception)) {
|
||||
throw DuplicateGameAccountException::forUsername();
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$accountId = (int) $connection->getPdo()->lastInsertId();
|
||||
|
||||
$connection->insert(
|
||||
'INSERT IGNORE INTO realmcharacters (realmid, acctid, numchars) SELECT id, ?, 0 FROM realmlist',
|
||||
[$accountId],
|
||||
);
|
||||
|
||||
$connection->statement(
|
||||
'INSERT INTO account_access (id, gmlevel, RealmID, comment) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE gmlevel = VALUES(gmlevel), comment = VALUES(comment)',
|
||||
[
|
||||
$accountId,
|
||||
config('moonwell.registration.gmlevel'),
|
||||
config('moonwell.registration.realm_id'),
|
||||
config('moonwell.registration.access_comment'),
|
||||
],
|
||||
);
|
||||
|
||||
return [
|
||||
'id' => $accountId,
|
||||
'username' => $normalizedUsername,
|
||||
];
|
||||
}, 3);
|
||||
}
|
||||
|
||||
private function accountExists(ConnectionInterface $connection, string $column, string $value): bool
|
||||
{
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
|
||||
if (! function_exists('mb_split')) {
|
||||
function mb_split(string $pattern, string $string, int $limit = -1): array|false
|
||||
{
|
||||
$delimiter = '/';
|
||||
$escapedPattern = str_replace($delimiter, '\\'.$delimiter, $pattern);
|
||||
|
||||
return preg_split($delimiter.$escapedPattern.$delimiter.'u', $string, $limit);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user