diff --git a/.env.example b/.env.example index d9a2944..072b30d 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,9 @@ APP_LOCALE=ru APP_FALLBACK_LOCALE=ru APP_FAKER_LOCALE=ru_RU +# Optional country header supplied by your CDN/reverse proxy; browser language has priority. +MOONWELL_LOCALE_COUNTRY_HEADER=CF-IPCountry + APP_MAINTENANCE_DRIVER=file # APP_MAINTENANCE_STORE=database @@ -62,9 +65,17 @@ MAIL_HOST=127.0.0.1 MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null -MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_ADDRESS="no-reply@moon-well.online" MAIL_FROM_NAME="${APP_NAME}" +# Production: use the Stalwart server from compose.mail.yaml (see docs/mail-server.md). +# MAIL_MAILER=smtp +# MAIL_SCHEME=smtps +# MAIL_HOST=mail.moon-well.online +# MAIL_PORT=465 +# MAIL_USERNAME=no-reply@moon-well.online +# MAIL_PASSWORD= + MOONWELL_OFFER_PUBLISHED=true MOONWELL_PRIVACY_PUBLISHED=true MOONWELL_RULES_PUBLISHED=true diff --git a/README.md b/README.md index 77b7706..378fc05 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,24 @@ Laravel 12 сайт для World of Warcraft 1.12.1 на vMaNGOS (`/home/sindo/c Настройка индексации, подтверждение домена и отправка sitemap описаны в [инструкции SEO для Яндекса и Google](docs/seo.md). +Проект использует PHP 8.5.12 или новее в ветке 8.x. Production-образ закреплён +на `php:8.5.12-fpm-bookworm`, локальный Sail устанавливает PHP 8.5 и проверяет +минимальную версию 8.5.12 при сборке. Composer использует ту же минимальную +версию. Она включает исправления CVE-2026-109618 и CVE-2026-109619; +`session.upload_progress.enabled=Off` также установлено в CLI/FPM согласно +[рекомендации PHP](https://www.php.net/archive/2026.php#2026-10-23-1). + +После обновления PHP пересоберите контейнеры. Для production выполните +`./scripts/deploy-production.sh` (с `--ssh-tunnel`, если требуется): скрипт +загрузит свежий базовый образ и пересоздаст приложение и nginx. Для Sail: + +```bash +./vendor/bin/sail build --pull +./vendor/bin/sail up -d --no-deps --force-recreate laravel.test +./vendor/bin/sail php -v +./vendor/bin/sail composer check-platform-reqs +``` + ## Подключение к локальному vMaNGOS Локальный стенд ядра описан в `/home/sindo/core/deploy/local/README.md`. Сначала запустите его. Контейнер сайта подключается к сети `vmangos-local_default` и обращается к MariaDB как `db:3306`. Пароль пользователя `vmangos` находится в `/home/sindo/core/deploy/local/secrets/db_password`; скопируйте его в `VMANGOS_AUTH_DB_PASSWORD` и `VMANGOS_CHARACTERS_DB_PASSWORD` своего `.env`. Текущий локальный `.env` сайта уже настроен на этот стенд. @@ -151,10 +169,31 @@ VMANGOS_TIMEZONE=Europe/Samara отдельные `MOONWELL_LAUNCHER_BASE_PATH` и `MOONWELL_LAUNCHER_MANIFEST_KEY`, как описано выше. Старые файлы клиента 3.3.5a для vMaNGOS не подходят. +## Языки интерфейса + +Сайт поддерживает русский и английский, включая кабинет, админку, письма, +ошибки форм и полные тексты оферты, политики конфиденциальности и правил. +Авторские тексты новостей и названия товаров сохраняются на языке публикации. + +Язык выбирается в таком порядке: сохранённый выбор в переключателе RU/EN, +поддерживаемый язык браузера из `Accept-Language` с учётом приоритетов, страна +из заголовка reverse proxy/CDN. Для RU, BY, KZ и KG используется русский, +для остальных стран — английский. Если браузер сообщает только другие языки, +используется английский; при отсутствии всех данных — русский. Режим «Авто» +удаляет сохранённый выбор. Ручной выбор хранится в cookie один год. + +Название заголовка страны задаёт `MOONWELL_LOCALE_COUNTRY_HEADER` +(по умолчанию `CF-IPCountry`). Заголовок должен заполнять прокси/CDN; +само приложение не обращается к внешним сервисам геолокации. +Настройки выбора находятся в `config/localization.php`, словари интерфейса — +в `lang/en.json` и `lang/ru.json`, английские юридические документы — +в `resources/js/Pages/Legal/en/`. + ## Проверка ```bash ./vendor/bin/sail artisan test +npm run test:translations npm run build ``` diff --git a/app/Exceptions/DuplicateGameAccountException.php b/app/Exceptions/DuplicateGameAccountException.php index 25bf5a2..f0f5694 100644 --- a/app/Exceptions/DuplicateGameAccountException.php +++ b/app/Exceptions/DuplicateGameAccountException.php @@ -15,11 +15,11 @@ class DuplicateGameAccountException extends RuntimeException public static function forUsername(): self { - return new self('username', 'Такой игровой аккаунт уже существует.'); + return new self('username', __('Такой игровой аккаунт уже существует.')); } public static function forEmail(): self { - return new self('email', 'На этот email уже зарегистрирован аккаунт.'); + return new self('email', __('На этот email уже зарегистрирован аккаунт.')); } } diff --git a/app/Exceptions/InviteCodeException.php b/app/Exceptions/InviteCodeException.php index f9e2858..251f3d5 100644 --- a/app/Exceptions/InviteCodeException.php +++ b/app/Exceptions/InviteCodeException.php @@ -10,11 +10,11 @@ class InviteCodeException extends RuntimeException public static function invalid(): self { - return new self('Укажи корректный инвайт-код.'); + return new self(__('Укажи корректный инвайт-код.')); } public static function alreadyUsed(): self { - return new self('Этот инвайт-код уже использован.'); + return new self(__('Этот инвайт-код уже использован.')); } } diff --git a/app/Http/Controllers/Admin/AccountController.php b/app/Http/Controllers/Admin/AccountController.php index 64da324..832608d 100644 --- a/app/Http/Controllers/Admin/AccountController.php +++ b/app/Http/Controllers/Admin/AccountController.php @@ -35,8 +35,8 @@ class AccountController extends Controller $accountItems = $accounts->listAccounts($searchTerm, $activeTab) ->map(fn (array $account): array => [ ...collect($account)->except('characters')->all(), - 'email' => $account['email'] !== '' ? $account['email'] : 'email не указан', - 'status_label' => $account['locked'] ? 'Заблокирован' : 'Активен', + 'email' => $account['email'] !== '' ? $account['email'] : __('email не указан'), + 'status_label' => $account['locked'] ? __('Заблокирован') : __('Активен'), 'characters' => $account['characters']->values()->all(), 'update_access_url' => route('admin.accounts.access.update', $account['id']), ]) @@ -48,9 +48,9 @@ class AccountController extends Controller 'id' => $invite->id, 'code' => $invite->code, 'created_by' => $invite->created_by_username ?: '—', - 'created_at' => $invite->created_at?->format('d.m.Y H:i'), + 'created_at' => \App\Support\LocaleFormatting::date($invite->created_at), 'redeemed' => $invite->redeemed_at !== null, - 'redeemed_by' => $invite->redeemed_by_username ?: 'ещё не использован', + 'redeemed_by' => $invite->redeemed_by_username ?: __('ещё не использован'), ]) ->values() ->all(); @@ -61,11 +61,11 @@ class AccountController extends Controller 'accountStats' => $accountStats, 'accountTabs' => [ VMangosAccountService::ACCOUNT_TAB_PLAYERS => [ - 'label' => 'Игроки', + 'label' => __('Игроки'), 'count' => $accountStats[VMangosAccountService::ACCOUNT_TAB_PLAYERS] ?? 0, ], VMangosAccountService::ACCOUNT_TAB_BOTS => [ - 'label' => 'Боты', + 'label' => __('Боты'), 'count' => $accountStats[VMangosAccountService::ACCOUNT_TAB_BOTS] ?? 0, ], ], @@ -91,6 +91,6 @@ class AccountController extends Controller $accounts->updateAccessLevel($accountId, (int) $request->input('gmlevel')); - return back()->with('status', 'Уровень доступа обновлён.'); + return back()->with('status', __('Уровень доступа обновлён.')); } } diff --git a/app/Http/Controllers/Admin/BalanceController.php b/app/Http/Controllers/Admin/BalanceController.php index 9bd09b9..6d8a372 100644 --- a/app/Http/Controllers/Admin/BalanceController.php +++ b/app/Http/Controllers/Admin/BalanceController.php @@ -27,14 +27,14 @@ class BalanceController extends Controller return [ 'id' => $transaction->id, - 'username' => $transaction->username ?: 'Аккаунт удалён', + 'username' => $transaction->username ?: __('Аккаунт удалён'), 'account_id' => $transaction->account_id, - 'created_at' => $transaction->created_at?->format('d.m.Y H:i:s'), + 'created_at' => \App\Support\LocaleFormatting::date($transaction->created_at, true), 'type_label' => match ($transaction->type) { - 'deposit' => 'Пополнение', - 'spend' => 'Покупка в игре', - 'admin_credit' => 'Начисление администратором', - 'admin_debit' => 'Списание администратором', + 'deposit' => __('Пополнение'), + 'spend' => __('Покупка в игре'), + 'admin_credit' => __('Начисление администратором'), + 'admin_debit' => __('Списание администратором'), default => $transaction->type, }, 'description' => $metadata['reason'] ?? $metadata['description'] ?? $transaction->reference, @@ -43,9 +43,9 @@ class BalanceController extends Controller 'account_id' => $metadata['admin_account_id'], ] : null, 'amount' => (string) $transaction->amount, - 'formatted_amount' => number_format((float) $transaction->amount, 2, ',', ' '), + 'formatted_amount' => \App\Support\LocaleFormatting::number((float) $transaction->amount, 2), 'is_positive' => (float) $transaction->amount >= 0, - 'formatted_balance_after' => number_format((float) $transaction->balance_after, 2, ',', ' '), + 'formatted_balance_after' => \App\Support\LocaleFormatting::number((float) $transaction->balance_after, 2), ]; }) ->values() @@ -76,7 +76,7 @@ class BalanceController extends Controller : $accounts->findUserByUsername($accountInput); if (! $account) { - return back()->withInput()->withErrors(['account' => 'Игровой аккаунт не найден.']); + return back()->withInput()->withErrors(['account' => __('Игровой аккаунт не найден.')]); } try { @@ -93,9 +93,9 @@ class BalanceController extends Controller } return back()->with('status', sprintf( - 'Счёт %s изменён. Теперь на нём %s Слёз Элуны.', + __('Счёт %s изменён. Теперь на нём %s Слёз Элуны.'), $account->username, - number_format((float) $transaction->balance_after, 2, ',', ' '), + \App\Support\LocaleFormatting::number((float) $transaction->balance_after, 2), )); } } diff --git a/app/Http/Controllers/Admin/InviteCodeController.php b/app/Http/Controllers/Admin/InviteCodeController.php index dd66d50..57a1b56 100644 --- a/app/Http/Controllers/Admin/InviteCodeController.php +++ b/app/Http/Controllers/Admin/InviteCodeController.php @@ -16,6 +16,6 @@ class InviteCodeController extends Controller $request->user(), ); - return back()->with('status', sprintf('Создано инвайт-кодов: %d.', $created->count())); + return back()->with('status', sprintf(__('Создано инвайт-кодов: %d.'), $created->count())); } } diff --git a/app/Http/Controllers/Admin/LoginScreenNewsController.php b/app/Http/Controllers/Admin/LoginScreenNewsController.php index 8e24b2a..f1c59d6 100644 --- a/app/Http/Controllers/Admin/LoginScreenNewsController.php +++ b/app/Http/Controllers/Admin/LoginScreenNewsController.php @@ -29,7 +29,7 @@ class LoginScreenNewsController extends Controller 'sort_order' => $item->sort_order, 'is_published' => $item->is_published, 'updated_by' => $item->updated_by_username, - 'updated_at' => $item->updated_at?->format('d.m.Y H:i'), + 'updated_at' => \App\Support\LocaleFormatting::date($item->updated_at), 'update_url' => route('admin.login-screen-news.update', $item), 'destroy_url' => route('admin.login-screen-news.destroy', $item), ]) @@ -52,7 +52,7 @@ class LoginScreenNewsController extends Controller { $news->create($request->validated(), $request->user()); - return back()->with('status', 'Новость для логин-экрана добавлена.'); + return back()->with('status', __('Новость для логин-экрана добавлена.')); } public function update( @@ -62,13 +62,13 @@ class LoginScreenNewsController extends Controller ): RedirectResponse { $news->update($newsItem, $request->validated(), $request->user()); - return back()->with('status', 'Новость для логин-экрана обновлена.'); + return back()->with('status', __('Новость для логин-экрана обновлена.')); } public function destroy(LoginScreenNews $newsItem, LoginScreenNewsService $news): RedirectResponse { $news->delete($newsItem); - return back()->with('status', 'Новость для логин-экрана удалена.'); + return back()->with('status', __('Новость для логин-экрана удалена.')); } } diff --git a/app/Http/Controllers/Admin/NewsController.php b/app/Http/Controllers/Admin/NewsController.php index 95ecc0b..2b29307 100644 --- a/app/Http/Controllers/Admin/NewsController.php +++ b/app/Http/Controllers/Admin/NewsController.php @@ -29,7 +29,7 @@ class NewsController extends Controller 'is_published' => $item->is_published, 'image_url' => $item->image_url, 'created_by' => $item->created_by_username ?? '—', - 'updated_at' => $item->updated_at?->format('d.m.Y H:i'), + 'updated_at' => \App\Support\LocaleFormatting::date($item->updated_at), 'update_url' => route('admin.news.update', $item), 'destroy_url' => route('admin.news.destroy', $item), ]) @@ -50,7 +50,7 @@ class NewsController extends Controller { $news->create($request->validated(), $request->user(), $request->file('image')); - return back()->with('status', 'Новость добавлена.'); + return back()->with('status', __('Новость добавлена.')); } public function update( @@ -60,13 +60,13 @@ class NewsController extends Controller ): RedirectResponse { $news->update($newsItem, $request->validated(), $request->file('image')); - return back()->with('status', 'Новость обновлена.'); + return back()->with('status', __('Новость обновлена.')); } public function destroy(News $newsItem, NewsService $news): RedirectResponse { $news->delete($newsItem); - return back()->with('status', 'Новость удалена.'); + return back()->with('status', __('Новость удалена.')); } } diff --git a/app/Http/Controllers/Admin/ShopController.php b/app/Http/Controllers/Admin/ShopController.php index 31e784a..d6786f2 100644 --- a/app/Http/Controllers/Admin/ShopController.php +++ b/app/Http/Controllers/Admin/ShopController.php @@ -76,13 +76,13 @@ class ShopController extends Controller 'shopEnabled' => $availability->isEnabled(), 'availabilityUpdateUrl' => route('admin.shop.availability.update'), 'productTypes' => collect([ - 1 => 'Предмет', - 3 => 'Маунт', - 4 => 'Питомец', - 5 => 'Бафф', - 7 => 'Услуга персонажа', - 8 => 'Буст', - 9 => 'Титул', + 1 => __('Предмет'), + 3 => __('Маунт'), + 4 => __('Питомец'), + 5 => __('Бафф'), + 7 => __('Услуга персонажа'), + 8 => __('Буст'), + 9 => __('Титул'), ])->map(fn (string $label, int $value): array => compact('value', 'label'))->values()->all(), ]); } @@ -97,49 +97,49 @@ class ShopController extends Controller $availability->setEnabled($enabled); return back()->with('status', $enabled - ? 'Магазин и пополнение включены.' - : 'Магазин и пополнение выключены.'); + ? __('Магазин и пополнение включены.') + : __('Магазин и пополнение выключены.')); } public function storeCategory(SaveShopCategoryRequest $request, ShopCatalogService $catalog): RedirectResponse { $catalog->createCategory($request->validated()); - return back()->with('status', 'Категория создана.'); + return back()->with('status', __('Категория создана.')); } public function updateCategory(ShopCategory $category, SaveShopCategoryRequest $request, ShopCatalogService $catalog): RedirectResponse { $catalog->updateCategory($category, $request->validated()); - return back()->with('status', 'Категория обновлена.'); + return back()->with('status', __('Категория обновлена.')); } public function destroyCategory(ShopCategory $category, ShopCatalogService $catalog): RedirectResponse { $catalog->deleteCategory($category); - return back()->with('status', 'Категория удалена.'); + return back()->with('status', __('Категория удалена.')); } public function storeProduct(SaveShopProductRequest $request, ShopCatalogService $catalog): RedirectResponse { $catalog->createProduct($request->validated()); - return back()->with('status', 'Товар создан.'); + return back()->with('status', __('Товар создан.')); } public function updateProduct(ShopProduct $product, SaveShopProductRequest $request, ShopCatalogService $catalog): RedirectResponse { $catalog->updateProduct($product, $request->validated()); - return back()->with('status', 'Товар обновлён.'); + return back()->with('status', __('Товар обновлён.')); } public function destroyProduct(ShopProduct $product, ShopCatalogService $catalog): RedirectResponse { $catalog->deleteProduct($product); - return back()->with('status', 'Товар удалён.'); + return back()->with('status', __('Товар удалён.')); } } diff --git a/app/Http/Controllers/Api/LauncherAuthController.php b/app/Http/Controllers/Api/LauncherAuthController.php index 179a853..a7375e2 100644 --- a/app/Http/Controllers/Api/LauncherAuthController.php +++ b/app/Http/Controllers/Api/LauncherAuthController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Requests\LoginRequest; use App\Models\GameAccount; +use App\Models\User; use App\Services\VMangosAccountService; use Illuminate\Http\JsonResponse; use OpenApi\Attributes as OA; @@ -62,6 +63,7 @@ class LauncherAuthController extends Controller ), ), new OA\Response(response: 429, description: 'Слишком много запросов'), + new OA\Response(response: 403, description: 'Требуется подтверждение email; verification_url содержит страницу повторной отправки письма'), ], )] public function login(LoginRequest $request): JsonResponse @@ -70,10 +72,19 @@ class LauncherAuthController extends Controller if (! $user || ! $this->accounts->validatePassword($user, $request->input('password'))) { return response()->json([ - 'message' => 'Неверный логин или пароль.', + 'message' => __('Неверный логин или пароль.'), ], 401); } + $webUser = $user->webUserId !== null ? User::find($user->webUserId) : null; + if ($webUser?->needsEmailVerification()) { + return response()->json([ + 'message' => __('Подтверди email, чтобы войти в аккаунт.'), + 'verification_required' => true, + 'verification_url' => route('verification.notice'), + ], 403); + } + $gameAccount = GameAccount::on('vmangos_auth')->findOrFail($user->id); $token = $gameAccount->createToken('launcher'); diff --git a/app/Http/Controllers/Api/LauncherController.php b/app/Http/Controllers/Api/LauncherController.php index 7136546..345c4cc 100644 --- a/app/Http/Controllers/Api/LauncherController.php +++ b/app/Http/Controllers/Api/LauncherController.php @@ -62,7 +62,7 @@ class LauncherController extends Controller $key = config('moonwell.launcher.manifest_key'); if (! $disk->exists($key)) { - return response()->json(['message' => 'Манифест не найден.'], 404); + return response()->json(['message' => __('Манифест не найден.')], 404); } $manifest = json_decode($disk->get($key), true); @@ -121,12 +121,12 @@ class LauncherController extends Controller { $disk = Storage::disk($this->disk()); if (! $this->validRelativePath($path)) { - return response()->json(['message' => 'Недопустимый путь.'], 422); + return response()->json(['message' => __('Недопустимый путь.')], 422); } $manifestKey = config('moonwell.launcher.manifest_key'); if (! $disk->exists($manifestKey)) { - return response()->json(['message' => 'Манифест не найден.'], 404); + return response()->json(['message' => __('Манифест не найден.')], 404); } $manifest = json_decode($disk->get($manifestKey), true); @@ -134,7 +134,7 @@ class LauncherController extends Controller fn ($file) => is_array($file) && ($file['path'] ?? null) === $path, ); if ($file === null) { - return response()->json(['message' => 'Файл не найден в манифесте.'], 404); + return response()->json(['message' => __('Файл не найден в манифесте.')], 404); } // New Rust deployments pin each file to an immutable release; legacy inventories use the base path. @@ -142,11 +142,11 @@ class LauncherController extends Controller $fullPath = $file['objectKey'] ?? $basePath.'/'.$path; if (! is_string($fullPath) || ! $this->validRelativePath($fullPath) || ! str_starts_with($fullPath, $basePath.'/')) { - return response()->json(['message' => 'Некорректный ключ файла в манифесте.'], 500); + return response()->json(['message' => __('Некорректный ключ файла в манифесте.')], 500); } if (! $disk->exists($fullPath)) { - return response()->json(['message' => 'Файл не найден.'], 404); + return response()->json(['message' => __('Файл не найден.')], 404); } $ttlMinutes = config('moonwell.launcher.download_ttl', 60); diff --git a/app/Http/Controllers/Api/LauncherRealmController.php b/app/Http/Controllers/Api/LauncherRealmController.php index 484069d..a3c250a 100644 --- a/app/Http/Controllers/Api/LauncherRealmController.php +++ b/app/Http/Controllers/Api/LauncherRealmController.php @@ -66,7 +66,7 @@ class LauncherRealmController extends Controller report($exception); return response()->json([ - 'message' => 'Не удалось получить список реалмов.', + 'message' => __('Не удалось получить список реалмов.'), ], 503); } } diff --git a/app/Http/Controllers/Api/LauncherRegistrationController.php b/app/Http/Controllers/Api/LauncherRegistrationController.php index 256f1c7..13aca59 100644 --- a/app/Http/Controllers/Api/LauncherRegistrationController.php +++ b/app/Http/Controllers/Api/LauncherRegistrationController.php @@ -17,7 +17,7 @@ class LauncherRegistrationController extends Controller #[OA\Post( path: '/api/launcher/register', summary: 'Зарегистрировать игровой аккаунт', - description: 'Создаёт аккаунт сайта и vMaNGOS со случайным игровым паролем. Для входа в игру лаунчер выдаёт билет. Инвайт-код обязателен, если включена регистрация по приглашениям.', + description: 'Создаёт аккаунт сайта и vMaNGOS со случайным игровым паролем и отправляет письмо подтверждения email. Вход в сайт и лаунчер доступен после подтверждения. Для входа в игру лаунчер выдаёт билет. Инвайт-код обязателен, если включена регистрация по приглашениям.', tags: ['Launcher Auth'], requestBody: new OA\RequestBody( required: true, @@ -40,6 +40,8 @@ class LauncherRegistrationController extends Controller content: new OA\JsonContent( properties: [ new OA\Property(property: 'message', type: 'string', example: 'Игровой аккаунт успешно создан.'), + new OA\Property(property: 'verification_required', type: 'boolean', example: true), + new OA\Property(property: 'verification_url', type: 'string', format: 'uri', example: 'https://moon-well.online/email/verify'), new OA\Property( property: 'account', properties: [ @@ -110,12 +112,14 @@ class LauncherRegistrationController extends Controller report($exception); return response()->json([ - 'message' => 'Не удалось создать аккаунт. Попробуй снова позже.', + 'message' => __('Не удалось создать аккаунт. Попробуй снова позже.'), ], 500); } return response()->json([ - 'message' => 'Игровой аккаунт успешно создан.', + 'message' => __('Игровой аккаунт успешно создан.'), + 'verification_required' => true, + 'verification_url' => route('verification.notice'), 'account' => [ 'id' => $account['id'], 'username' => $account['username'], diff --git a/app/Http/Controllers/Auth/AccountAccessController.php b/app/Http/Controllers/Auth/AccountAccessController.php new file mode 100644 index 0000000..20bef8b --- /dev/null +++ b/app/Http/Controllers/Auth/AccountAccessController.php @@ -0,0 +1,141 @@ +page($request, 'forgot'); + } + + public function sendResetLink(AccountEmailRequest $request): RedirectResponse + { + try { + Password::broker('web_users')->sendResetLink($request->safe()->only('email')); + } catch (Throwable $exception) { + report($exception); + + return back()->with('error', __('Не удалось отправить письмо. Попробуй ещё раз через минуту.'))->onlyInput('email'); + } + + return back()->with('status', __('Если аккаунт с такой почтой существует, письмо со ссылкой для восстановления придёт в течение нескольких минут. Проверь также папку «Спам».'))->onlyInput('email'); + } + + public function reset(Request $request, string $token): Response + { + return $this->page($request, 'reset', ['token' => $token]); + } + + public function updatePassword(ResetAccountPasswordRequest $request): RedirectResponse + { + // Keep token validation, password update and token consumption atomic. + $status = DB::transaction(function () use ($request): string { + User::where('email', $request->string('email')->toString())->lockForUpdate()->first(); + + return Password::broker('web_users')->reset( + $request->safe()->only(['email', 'password', 'password_confirmation', 'token']), + function (User $user, string $password): void { + $user->forceFill(['password' => $password, 'remember_token' => Str::random(60)])->save(); + event(new PasswordReset($user)); + }, + ); + }); + + if ($status !== Password::PASSWORD_RESET) { + return back()->withErrors(['token' => __('Ссылка недействительна или срок её действия истёк. Запроси новое письмо.')])->onlyInput('email'); + } + + return redirect()->route('login')->with('status', __('Пароль обновлён. Войди в аккаунт с новым паролем.')); + } + + public function notice(Request $request): Response + { + return $this->page($request, 'verification'); + } + + public function resend(AccountEmailRequest $request, AccountEmailService $emails): RedirectResponse + { + $user = User::whereRaw('LOWER(email) = ?', [$request->string('email')->toString()])->first(); + if ($user !== null) { + $emails->sendVerification($user); + } + + return back()->with('status', __('Если этот email ожидает подтверждения, мы отправим письмо. Повторный запрос доступен через минуту. Проверь также папку «Спам».'))->onlyInput('email'); + } + + public function verify(Request $request, string $id, string $hash): Response + { + if (! $request->hasValidSignature()) { + return $this->page($request, 'invalid'); + } + + $user = User::find($id); + if ($user === null || ! hash_equals(sha1($user->getEmailForVerification()), $hash)) { + return $this->page($request, 'invalid'); + } + + if (! $user->hasVerifiedEmail() && $user->markEmailAsVerified()) { + event(new Verified($user)); + } + + return $this->page($request, 'verified'); + } + + private function page(Request $request, string $mode, array $extra = []): Response + { + $email = $mode === 'reset' + ? $request->query('email', '') + : $request->old('email', $mode === 'verification' ? $request->session()->get('verification_email', '') : ''); + + return Inertia::render('Auth/AccountAccess', [ + 'site' => [ + 'name' => config('moonwell.realm.server_name'), + 'tagline' => config('moonwell.realm.tagline'), + ], + 'auth' => [ + 'authenticated' => $request->user() !== null, + 'username' => $request->user()?->username, + 'access_label' => $request->user()?->accessLabel(), + 'is_admin' => $request->user()?->canAccessAdminPanel() ?? false, + ], + 'urls' => [ + 'home' => route('landing.index'), + 'login' => route('login'), + 'cabinet' => route('cabinet.index'), + 'logout' => route('logout'), + 'forgot' => route('password.request'), + 'send_reset' => route('password.email'), + 'reset' => route('password.update'), + 'verification' => route('verification.notice'), + 'resend' => route('verification.send'), + 'offer' => config('moonwell.legal.offer_published') ? route('legal.offer') : null, + 'privacy' => config('moonwell.legal.privacy_published') ? route('legal.privacy') : null, + 'rules' => config('moonwell.legal.rules_published') ? route('legal.rules') : null, + ], + 'flash' => [ + 'status' => $request->session()->get('status'), + 'error' => $request->session()->get('error'), + ], + 'mode' => $mode, + 'email' => is_string($email) ? $email : '', + ...$extra, + ]); + } +} diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 24b69e7..8d366cd 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; use App\Http\Requests\LoginRequest; +use App\Models\User; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -29,6 +30,8 @@ class LoginController extends Controller 'home' => route('landing.index'), 'login' => route('login'), 'login_store' => route('login.store'), + 'forgot' => route('password.request'), + 'verification' => route('verification.notice'), 'offer' => config('moonwell.legal.offer_published') ? route('legal.offer') : null, 'privacy' => config('moonwell.legal.privacy_published') ? route('legal.privacy') : null, 'rules' => config('moonwell.legal.rules_published') ? route('legal.rules') : null, @@ -47,10 +50,22 @@ class LoginController extends Controller { if (! Auth::attempt($request->safe()->only(['username', 'password']))) { return back() - ->withErrors(['username' => 'Неверный логин или пароль.']) + ->withErrors(['username' => __('Неверный логин или пароль.')]) ->onlyInput('username'); } + $webUserId = $request->user()?->webUserId; + $webUser = $webUserId !== null ? User::find($webUserId) : null; + if ($webUser?->needsEmailVerification()) { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('verification.notice') + ->with('verification_email', $webUser->email) + ->with('status', __('Подтверди email, чтобы войти в аккаунт.')); + } + $request->session()->regenerate(); return redirect()->intended(route('cabinet.index')); @@ -65,6 +80,6 @@ class LoginController extends Controller return redirect() ->route('login') - ->with('status', 'Сеанс завершён. Можно войти снова.'); + ->with('status', __('Сеанс завершён. Можно войти снова.')); } } diff --git a/app/Http/Controllers/CabinetController.php b/app/Http/Controllers/CabinetController.php index 655537a..b63d1ea 100644 --- a/app/Http/Controllers/CabinetController.php +++ b/app/Http/Controllers/CabinetController.php @@ -47,7 +47,7 @@ class CabinetController extends Controller 'joined_at' => $user->joinedAtLabel(), 'last_login_at' => $user->lastLoginLabel(), 'locked' => $user->locked, - 'status_label' => $user->locked ? 'Заблокирован' : 'Активен', + 'status_label' => $user->locked ? __('Заблокирован') : __('Активен'), ], 'characters' => $characters->all(), 'client' => [ @@ -56,10 +56,10 @@ class CabinetController extends Controller 'wallet' => [ 'deposit_enabled' => $storeAvailability->isEnabled(), 'amount' => $balance, - 'formatted_amount' => number_format((float) $balance, 2, ',', ' '), + 'formatted_amount' => \App\Support\LocaleFormatting::number((float) $balance, 2), 'minimum_deposit' => (float) config('services.robokassa.minimum_amount'), 'maximum_deposit' => (float) config('services.robokassa.maximum_amount'), - 'formatted_maximum_deposit' => number_format((float) config('services.robokassa.maximum_amount'), 0, ',', ' '), + 'formatted_maximum_deposit' => \App\Support\LocaleFormatting::number((float) config('services.robokassa.maximum_amount'), 0), 'tears_per_ruble' => (int) config('services.robokassa.tears_per_ruble'), 'deposit_amount' => $request->old('amount', 500), 'transactions' => $balances->recentTransactions($user->id) @@ -67,15 +67,15 @@ class CabinetController extends Controller 'id' => $transaction->id, 'type' => $transaction->type, 'label' => match ($transaction->type) { - 'deposit' => 'Пополнение', - 'admin_credit' => 'Начисление', - 'admin_debit' => 'Списание', - default => 'Покупка в игре', + 'deposit' => __('Пополнение'), + 'admin_credit' => __('Начисление'), + 'admin_debit' => __('Списание'), + default => __('Покупка в игре'), }, 'amount' => (string) $transaction->amount, - 'formatted_amount' => number_format((float) $transaction->amount, 2, ',', ' '), + 'formatted_amount' => \App\Support\LocaleFormatting::number((float) $transaction->amount, 2), 'is_positive' => (float) $transaction->amount >= 0, - 'created_at' => $transaction->created_at?->format('d.m.Y H:i'), + 'created_at' => \App\Support\LocaleFormatting::date($transaction->created_at), ]) ->values() ->all(), @@ -88,12 +88,12 @@ class CabinetController extends Controller $user = $request->user(); if (! $accounts->validatePassword($user, $request->string('current_password')->toString())) { - return back()->withErrors(['current_password' => 'Текущий пароль указан неверно.']); + return back()->withErrors(['current_password' => __('Текущий пароль указан неверно.')]); } $accounts->updatePassword($user, $request->string('password')->toString()); - return back()->with('status', 'Пароль успешно обновлён.'); + return back()->with('status', __('Пароль успешно обновлён.')); } public function client(GameClientDownloadService $clientDownloadService): RedirectResponse @@ -103,7 +103,7 @@ class CabinetController extends Controller } catch (Throwable $exception) { report($exception); - return back()->with('error', 'Не удалось получить ссылку на клиент. Попробуй ещё раз позже.'); + return back()->with('error', __('Не удалось получить ссылку на клиент. Попробуй ещё раз позже.')); } } diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index 524ad70..7b1d045 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -44,7 +44,7 @@ class LandingController extends Controller 'title' => $item->title, 'excerpt' => Str::limit(trim(strip_tags($item->body)), 200), 'date' => $item->created_at?->locale(app()->getLocale())->isoFormat('D MMMM YYYY'), - 'tag' => 'Новости', + 'tag' => __('Новости'), 'tagType' => 'patch', 'image_url' => $item->image_url, 'featured' => $index === 0, @@ -141,14 +141,15 @@ class LandingController extends Controller report($exception); return back() - ->with('registration_error', 'Не удалось создать аккаунт. Проверь настройки сервера и попробуй снова.') + ->with('registration_error', __('Не удалось создать аккаунт. Проверь настройки сервера и попробуй снова.')) ->withInput($request->safe()->only(['username', 'email', 'invite_code'])); } return redirect() - ->route('landing.index') - ->with('registration_success', sprintf('Игровой аккаунт %s успешно создан. Теперь можно входить в клиент WoW.', $result['username'])) - ->with('registration_username', $result['username']); + ->route('verification.notice') + ->with('registration_success', true) + ->with('verification_email', strtolower(trim($request->string('email')->toString()))) + ->with('status', sprintf(__('Аккаунт %s создан. Подтверди email по ссылке в письме. Если письмо не пришло, запроси его повторно ниже.'), $result['username'])); } /** diff --git a/app/Http/Controllers/LocaleController.php b/app/Http/Controllers/LocaleController.php new file mode 100644 index 0000000..25e4532 --- /dev/null +++ b/app/Http/Controllers/LocaleController.php @@ -0,0 +1,23 @@ +validate(['locale' => ['required', Rule::in(['auto', ...config('localization.supported')])]])['locale']; + if ($locale === 'auto') { + Cookie::queue(Cookie::forget('moonwell_locale')); + } else { + Cookie::queue(cookie('moonwell_locale', $locale, 60 * 24 * 365, '/', null, $request->isSecure(), true, false, 'lax')); + } + + return back(); + } +} diff --git a/app/Http/Controllers/MainController.php b/app/Http/Controllers/MainController.php index eaa7803..8bcc8be 100644 --- a/app/Http/Controllers/MainController.php +++ b/app/Http/Controllers/MainController.php @@ -33,7 +33,7 @@ class MainController extends Controller 'title' => $item->title, 'excerpt' => Str::limit(trim(strip_tags($item->body)), 200), 'date' => $item->created_at?->locale(app()->getLocale())->isoFormat('D MMMM YYYY'), - 'tag' => 'Новости', + 'tag' => __('Новости'), 'tagType' => 'patch', 'image_url' => $item->image_url, 'featured' => $index === 0, diff --git a/app/Http/Controllers/PaymentController.php b/app/Http/Controllers/PaymentController.php index ea150d9..0507c91 100644 --- a/app/Http/Controllers/PaymentController.php +++ b/app/Http/Controllers/PaymentController.php @@ -20,7 +20,7 @@ class PaymentController extends Controller if (! $storeAvailability->isEnabled()) { return redirect() ->route('cabinet.index') - ->with('error', 'Пополнение временно недоступно.'); + ->with('error', __('Пополнение временно недоступно.')); } $validated = $request->validate([ @@ -43,11 +43,11 @@ class PaymentController extends Controller public function success(): RedirectResponse { - return redirect()->route('cabinet.index')->with('status', 'Пополнение принято. Слёзы Элуны скоро появятся на счёте.'); + return redirect()->route('cabinet.index')->with('status', __('Пополнение принято. Слёзы Элуны скоро появятся на счёте.')); } public function fail(): RedirectResponse { - return redirect()->route('cabinet.index')->with('error', 'Оплата отменена или не была завершена.'); + return redirect()->route('cabinet.index')->with('error', __('Оплата отменена или не была завершена.')); } } diff --git a/app/Http/Middleware/EnsureGameMaster.php b/app/Http/Middleware/EnsureGameMaster.php index f850208..430038a 100644 --- a/app/Http/Middleware/EnsureGameMaster.php +++ b/app/Http/Middleware/EnsureGameMaster.php @@ -15,7 +15,7 @@ class EnsureGameMaster abort_unless( $user && $user->canAccessAdminPanel(), Response::HTTP_FORBIDDEN, - 'Недостаточно прав для доступа к админке.', + __('Недостаточно прав для доступа к админке.'), ); return $next($request); diff --git a/app/Http/Middleware/EnsureGameServer.php b/app/Http/Middleware/EnsureGameServer.php index f22b79f..dc74e6d 100644 --- a/app/Http/Middleware/EnsureGameServer.php +++ b/app/Http/Middleware/EnsureGameServer.php @@ -14,7 +14,7 @@ class EnsureGameServer $provided = (string) $request->bearerToken(); if ($expected === '' || $provided === '' || ! hash_equals($expected, $provided)) { - abort(401, 'Неверный токен игрового сервера.'); + abort(401, __('Неверный токен игрового сервера.')); } return $next($request); diff --git a/app/Http/Middleware/EnsureLauncherRequest.php b/app/Http/Middleware/EnsureLauncherRequest.php index 2b04c95..52f8012 100644 --- a/app/Http/Middleware/EnsureLauncherRequest.php +++ b/app/Http/Middleware/EnsureLauncherRequest.php @@ -14,7 +14,7 @@ class EnsureLauncherRequest filled(config('auth.launcher_auth_key')) && hash_equals(config('auth.launcher_auth_key'), (string) $request->header('Auth')), Response::HTTP_FORBIDDEN, - 'Недостаточно прав для доступа к админке.', + __('Недостаточно прав для доступа к админке.'), ); return $next($request); diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index c0ef788..7599286 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -38,6 +38,10 @@ class HandleInertiaRequests extends Middleware { return [ ...parent::share($request), + 'locale' => app()->getLocale(), + 'localePreference' => in_array($request->cookie('moonwell_locale'), config('localization.supported'), true) ? $request->cookie('moonwell_locale') : 'auto', + 'localeUrl' => route('locale.update'), + 'csrfToken' => csrf_token(), 'seo' => fn (): array => app(SeoService::class)->forRequest($request), ]; } diff --git a/app/Http/Middleware/ProtectAccountAccessPages.php b/app/Http/Middleware/ProtectAccountAccessPages.php new file mode 100644 index 0000000..70a21b9 --- /dev/null +++ b/app/Http/Middleware/ProtectAccountAccessPages.php @@ -0,0 +1,20 @@ +headers->set('Cache-Control', 'no-store, private'); + $response->headers->set('Referrer-Policy', 'no-referrer'); + $response->headers->set('X-Robots-Tag', 'noindex'); + + return $response; + } +} diff --git a/app/Http/Middleware/SetLocale.php b/app/Http/Middleware/SetLocale.php new file mode 100644 index 0000000..e8cfef8 --- /dev/null +++ b/app/Http/Middleware/SetLocale.php @@ -0,0 +1,49 @@ +setLocale($this->resolve($request)); + $response = $next($request); + $response->headers->set('Content-Language', app()->getLocale()); + $response->setVary(array_filter(['Accept-Language', 'Cookie', config('localization.country_header')]), false); + + return $response; + } + + private function resolve(Request $request): string + { + $supported = config('localization.supported'); + $choice = $request->cookie('moonwell_locale'); + if (in_array($choice, $supported, true)) { + return $choice; + } + + $languages = AcceptHeader::fromString($request->header('Accept-Language', '')); + foreach ($languages->all() as $language) { + if ($language->getQuality() <= 0) { + continue; + } + $locale = strtolower(explode('-', str_replace('_', '-', $language->getValue()))[0]); + if (in_array($locale, $supported, true)) { + return $locale; + } + } + + $header = config('localization.country_header'); + $country = $header ? strtoupper((string) $request->header($header, '')) : ''; + if (preg_match('/^[A-Z]{2}$/D', $country)) { + return in_array($country, config('localization.russian_countries'), true) ? 'ru' : 'en'; + } + + return $languages->all() ? 'en' : config('localization.fallback'); + } +} diff --git a/app/Http/Requests/AccountEmailRequest.php b/app/Http/Requests/AccountEmailRequest.php new file mode 100644 index 0000000..ffc700e --- /dev/null +++ b/app/Http/Requests/AccountEmailRequest.php @@ -0,0 +1,35 @@ +input('email'))) { + $this->merge(['email' => strtolower(trim($this->input('email')))]); + } + } + + public function rules(): array + { + return ['email' => ['required', 'string', 'email:rfc', 'max:255']]; + } + + public function messages(): array + { + return [ + 'email.required' => __('Укажи email аккаунта.'), + 'email.string' => __('Укажи корректный email.'), + 'email.email' => __('Укажи корректный email.'), + 'email.max' => __('Email не должен быть длиннее 255 символов.'), + ]; + } +} diff --git a/app/Http/Requests/ChangePasswordRequest.php b/app/Http/Requests/ChangePasswordRequest.php index 6fb655f..0f6d1b1 100644 --- a/app/Http/Requests/ChangePasswordRequest.php +++ b/app/Http/Requests/ChangePasswordRequest.php @@ -29,13 +29,13 @@ class ChangePasswordRequest extends FormRequest public function messages(): array { return [ - 'current_password.required' => 'Укажи текущий пароль.', - 'current_password.max' => 'Текущий пароль не должен быть длиннее 255 символов.', - 'password.required' => 'Укажи новый пароль.', - 'password.min' => 'Новый пароль должен содержать минимум 8 символов.', - 'password.max' => 'Новый пароль не должен быть длиннее 16 символов.', - 'password.confirmed' => 'Подтверждение нового пароля не совпадает.', - 'password.regex' => 'Новый пароль должен содержать хотя бы одну букву и одну цифру.', + 'current_password.required' => __('Укажи текущий пароль.'), + 'current_password.max' => __('Текущий пароль не должен быть длиннее 255 символов.'), + 'password.required' => __('Укажи новый пароль.'), + 'password.min' => __('Новый пароль должен содержать минимум 8 символов.'), + 'password.max' => __('Новый пароль не должен быть длиннее 16 символов.'), + 'password.confirmed' => __('Подтверждение нового пароля не совпадает.'), + 'password.regex' => __('Новый пароль должен содержать хотя бы одну букву и одну цифру.'), ]; } } diff --git a/app/Http/Requests/CreateInviteCodesRequest.php b/app/Http/Requests/CreateInviteCodesRequest.php index ac60fdb..6d76c63 100644 --- a/app/Http/Requests/CreateInviteCodesRequest.php +++ b/app/Http/Requests/CreateInviteCodesRequest.php @@ -27,10 +27,10 @@ class CreateInviteCodesRequest extends FormRequest public function messages(): array { return [ - 'quantity.required' => 'Укажи количество инвайт-кодов.', - 'quantity.integer' => 'Количество должно быть числом.', - 'quantity.min' => 'Нужно создать минимум один инвайт-код.', - 'quantity.max' => 'За один раз можно создать не больше 50 инвайт-кодов.', + 'quantity.required' => __('Укажи количество инвайт-кодов.'), + 'quantity.integer' => __('Количество должно быть числом.'), + 'quantity.min' => __('Нужно создать минимум один инвайт-код.'), + 'quantity.max' => __('За один раз можно создать не больше 50 инвайт-кодов.'), ]; } } diff --git a/app/Http/Requests/LoginRequest.php b/app/Http/Requests/LoginRequest.php index e6cd05a..2369cf3 100644 --- a/app/Http/Requests/LoginRequest.php +++ b/app/Http/Requests/LoginRequest.php @@ -29,13 +29,13 @@ class LoginRequest extends FormRequest public function messages(): array { return [ - 'username.required' => 'Укажи логин аккаунта.', - 'username.min' => 'Логин должен содержать минимум 3 символа.', - 'username.max' => 'Логин не должен быть длиннее 16 символов.', - 'username.regex' => 'Логин может содержать только латинские буквы и цифры.', - 'password.required' => 'Укажи пароль.', - 'password.min' => 'Пароль должен содержать минимум 3 символа.', - 'password.max' => 'Пароль не должен быть длиннее 255 символов.', + 'username.required' => __('Укажи логин аккаунта.'), + 'username.min' => __('Логин должен содержать минимум 3 символа.'), + 'username.max' => __('Логин не должен быть длиннее 16 символов.'), + 'username.regex' => __('Логин может содержать только латинские буквы и цифры.'), + 'password.required' => __('Укажи пароль.'), + 'password.min' => __('Пароль должен содержать минимум 3 символа.'), + 'password.max' => __('Пароль не должен быть длиннее 255 символов.'), ]; } } diff --git a/app/Http/Requests/RegisterGameAccountRequest.php b/app/Http/Requests/RegisterGameAccountRequest.php index ea759a7..15efeaa 100644 --- a/app/Http/Requests/RegisterGameAccountRequest.php +++ b/app/Http/Requests/RegisterGameAccountRequest.php @@ -31,26 +31,26 @@ class RegisterGameAccountRequest extends FormRequest public function messages(): array { return [ - 'username.required' => 'Укажи логин аккаунта.', - 'username.string' => 'Логин должен быть строкой.', - 'username.min' => 'Логин должен содержать минимум 3 символа.', - 'username.max' => 'Логин не должен быть длиннее 16 символов.', - 'username.regex' => 'Логин может содержать только латинские буквы и цифры.', - 'email.required' => 'Укажи email для аккаунта.', - '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 символов.', - 'password.max' => 'Пароль не должен быть длиннее 16 символов.', - 'password.confirmed' => 'Подтверждение пароля не совпадает.', - 'password.regex' => 'Пароль должен содержать хотя бы одну букву и одну цифру.', - 'terms.accepted' => 'Нужно принять правила сервера и пользовательское соглашение.', + 'username.required' => __('Укажи логин аккаунта.'), + 'username.string' => __('Логин должен быть строкой.'), + 'username.min' => __('Логин должен содержать минимум 3 символа.'), + 'username.max' => __('Логин не должен быть длиннее 16 символов.'), + 'username.regex' => __('Логин может содержать только латинские буквы и цифры.'), + 'email.required' => __('Укажи email для аккаунта.'), + '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 символов.'), + 'password.max' => __('Пароль не должен быть длиннее 16 символов.'), + 'password.confirmed' => __('Подтверждение пароля не совпадает.'), + 'password.regex' => __('Пароль должен содержать хотя бы одну букву и одну цифру.'), + 'terms.accepted' => __('Нужно принять правила сервера и пользовательское соглашение.'), ]; } @@ -60,11 +60,11 @@ class RegisterGameAccountRequest extends FormRequest public function attributes(): array { return [ - 'username' => 'логин аккаунта', + 'username' => __('логин аккаунта'), 'email' => 'email', - 'invite_code' => 'инвайт-код', - 'password' => 'пароль', - 'terms' => 'правила', + 'invite_code' => __('инвайт-код'), + 'password' => __('пароль'), + 'terms' => __('правила'), ]; } } diff --git a/app/Http/Requests/ResetAccountPasswordRequest.php b/app/Http/Requests/ResetAccountPasswordRequest.php new file mode 100644 index 0000000..6220610 --- /dev/null +++ b/app/Http/Requests/ResetAccountPasswordRequest.php @@ -0,0 +1,26 @@ + ['required', 'string', 'max:255'], + 'password' => (new ChangePasswordRequest)->rules()['password'], + ]; + } + + public function messages(): array + { + return [ + ...parent::messages(), + ...(new ChangePasswordRequest)->messages(), + 'token.required' => __('Ссылка недействительна. Запроси новое письмо.'), + 'token.string' => __('Ссылка недействительна. Запроси новое письмо.'), + 'token.max' => __('Ссылка недействительна. Запроси новое письмо.'), + ]; + } +} diff --git a/app/Http/Requests/SaveLoginScreenNewsRequest.php b/app/Http/Requests/SaveLoginScreenNewsRequest.php index d883334..8e1610c 100644 --- a/app/Http/Requests/SaveLoginScreenNewsRequest.php +++ b/app/Http/Requests/SaveLoginScreenNewsRequest.php @@ -37,14 +37,14 @@ class SaveLoginScreenNewsRequest extends FormRequest public function messages(): array { return [ - 'title.required' => 'Укажи название новости для админки.', - 'title.max' => 'Название новости не должно быть длиннее 120 символов.', - 'body.required' => 'Заполни текст новости для логин-экрана.', - 'body.max' => 'Текст новости слишком длинный.', - 'sort_order.required' => 'Укажи порядок вывода.', - 'sort_order.integer' => 'Порядок вывода должен быть числом.', - 'sort_order.min' => 'Порядок вывода не может быть отрицательным.', - 'sort_order.max' => 'Порядок вывода слишком большой.', + 'title.required' => __('Укажи название новости для админки.'), + 'title.max' => __('Название новости не должно быть длиннее 120 символов.'), + 'body.required' => __('Заполни текст новости для логин-экрана.'), + 'body.max' => __('Текст новости слишком длинный.'), + 'sort_order.required' => __('Укажи порядок вывода.'), + 'sort_order.integer' => __('Порядок вывода должен быть числом.'), + 'sort_order.min' => __('Порядок вывода не может быть отрицательным.'), + 'sort_order.max' => __('Порядок вывода слишком большой.'), ]; } } diff --git a/app/Http/Requests/SaveNewsRequest.php b/app/Http/Requests/SaveNewsRequest.php index 6c9599d..0f92612 100644 --- a/app/Http/Requests/SaveNewsRequest.php +++ b/app/Http/Requests/SaveNewsRequest.php @@ -38,16 +38,16 @@ class SaveNewsRequest extends FormRequest public function messages(): array { return [ - 'title.required' => 'Укажи заголовок новости.', - 'title.max' => 'Заголовок не должен быть длиннее 120 символов.', - 'body.required' => 'Заполни текст новости.', - 'body.max' => 'Текст новости слишком длинный.', - 'sort_order.required' => 'Укажи порядок вывода.', - 'sort_order.integer' => 'Порядок вывода должен быть числом.', - 'sort_order.min' => 'Порядок вывода не может быть отрицательным.', - 'sort_order.max' => 'Порядок вывода слишком большой.', - 'image.image' => 'Файл должен быть изображением.', - 'image.max' => 'Изображение не должно превышать 10 МБ.', + 'title.required' => __('Укажи заголовок новости.'), + 'title.max' => __('Заголовок не должен быть длиннее 120 символов.'), + 'body.required' => __('Заполни текст новости.'), + 'body.max' => __('Текст новости слишком длинный.'), + 'sort_order.required' => __('Укажи порядок вывода.'), + 'sort_order.integer' => __('Порядок вывода должен быть числом.'), + 'sort_order.min' => __('Порядок вывода не может быть отрицательным.'), + 'sort_order.max' => __('Порядок вывода слишком большой.'), + 'image.image' => __('Файл должен быть изображением.'), + 'image.max' => __('Изображение не должно превышать 10 МБ.'), ]; } } diff --git a/app/Http/Requests/UpdateAccountAccessRequest.php b/app/Http/Requests/UpdateAccountAccessRequest.php index 5e2dc70..8cca38d 100644 --- a/app/Http/Requests/UpdateAccountAccessRequest.php +++ b/app/Http/Requests/UpdateAccountAccessRequest.php @@ -28,9 +28,9 @@ class UpdateAccountAccessRequest extends FormRequest public function messages(): array { return [ - 'gmlevel.required' => 'Укажи уровень доступа.', - 'gmlevel.integer' => 'Уровень доступа должен быть числом.', - 'gmlevel.in' => 'Выбран недопустимый уровень доступа.', + 'gmlevel.required' => __('Укажи уровень доступа.'), + 'gmlevel.integer' => __('Уровень доступа должен быть числом.'), + 'gmlevel.in' => __('Выбран недопустимый уровень доступа.'), ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index 68f3a66..f39aef2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,13 +2,15 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Notifications\ResetAccountPassword; +use App\Notifications\VerifyAccountEmail; +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 +class User extends Authenticatable implements MustVerifyEmail { /** @use HasFactory */ use HasFactory, Notifiable; @@ -22,6 +24,7 @@ class User extends Authenticatable 'name', 'email', 'password', + 'email_verification_required', ]; /** @@ -44,6 +47,22 @@ class User extends Authenticatable return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'email_verification_required' => 'boolean', ]; } + + public function needsEmailVerification(): bool + { + return $this->email_verification_required && ! $this->hasVerifiedEmail(); + } + + public function sendEmailVerificationNotification(): void + { + $this->notify(new VerifyAccountEmail); + } + + public function sendPasswordResetNotification($token): void + { + $this->notify(new ResetAccountPassword($token)); + } } diff --git a/app/Notifications/ResetAccountPassword.php b/app/Notifications/ResetAccountPassword.php new file mode 100644 index 0000000..14f88b7 --- /dev/null +++ b/app/Notifications/ResetAccountPassword.php @@ -0,0 +1,26 @@ +from('no-reply@moon-well.online', 'MoonWell') + ->subject(__('Восстановление пароля — MoonWell')) + ->view('mail.account-action', [ + 'username' => $notifiable->name, + 'eyebrow' => __('Возвращение в MoonWell'), + 'title' => __('Задай новый пароль'), + 'intro' => __('Мы получили запрос на восстановление доступа к твоему аккаунту. Нажми кнопку, чтобы задать новый пароль для сайта и лаунчера.'), + 'action' => __('Восстановить пароль'), + 'url' => rtrim(config('app.url'), '/').route('password.reset', ['token' => $this->token, 'email' => $notifiable->getEmailForPasswordReset()], false), + 'expiry' => __('Ссылка действует 60 минут и может быть использована только один раз.'), + 'outro' => __('Если ты не запрашивал восстановление, просто проигнорируй письмо. Твой пароль останется прежним.'), + ]); + } +} diff --git a/app/Notifications/VerifyAccountEmail.php b/app/Notifications/VerifyAccountEmail.php new file mode 100644 index 0000000..293b965 --- /dev/null +++ b/app/Notifications/VerifyAccountEmail.php @@ -0,0 +1,39 @@ +forceRootUrl(config('app.url')); + $urls->forceScheme(parse_url(config('app.url'), PHP_URL_SCHEME)); + + return $urls->temporarySignedRoute('verification.verify', now()->addHour(), [ + 'id' => $notifiable->getKey(), + 'hash' => sha1($notifiable->getEmailForVerification()), + ]); + } + + public function toMail($notifiable): MailMessage + { + return (new MailMessage) + ->from('no-reply@moon-well.online', 'MoonWell') + ->subject(__('Подтверди email — MoonWell')) + ->view('mail.account-action', [ + 'username' => $notifiable->name, + 'eyebrow' => __('Ещё один шаг до Азерота'), + 'title' => __('Подтверди свой email'), + 'intro' => __('Добро пожаловать в MoonWell! Подтверди почту, чтобы войти в личный кабинет и лаунчер.'), + 'action' => __('Подтвердить email'), + 'url' => $this->verificationUrl($notifiable), + 'expiry' => __('Ссылка действует 60 минут. Новое письмо можно запросить на странице подтверждения.'), + 'outro' => __('Если ты не создавал аккаунт MoonWell, просто проигнорируй это письмо.'), + ]); + } +} diff --git a/app/Services/AccountEmailService.php b/app/Services/AccountEmailService.php new file mode 100644 index 0000000..44b0f3c --- /dev/null +++ b/app/Services/AccountEmailService.php @@ -0,0 +1,27 @@ +needsEmailVerification()) { + return; + } + + $key = 'email-verification:'.hash('sha256', strtolower($user->email)); + RateLimiter::attempt($key, 1, function () use ($user): void { + try { + $user->sendEmailVerificationNotification(); + } catch (Throwable $exception) { + // Registration is already committed; the user can request another email. + report($exception); + } + }, 60); + } +} diff --git a/app/Services/BalanceService.php b/app/Services/BalanceService.php index 9248647..77e81b0 100644 --- a/app/Services/BalanceService.php +++ b/app/Services/BalanceService.php @@ -33,14 +33,14 @@ class BalanceService { $cents = $this->toCents($amount); if ($cents <= 0) { - throw new InvalidArgumentException('Сумма списания должна быть больше нуля.'); + throw new InvalidArgumentException(__('Сумма списания должна быть больше нуля.')); } return DB::connection('vmangos_auth')->transaction(function () use ($accountId, $cents, $reference, $description): BalanceTransaction { $existing = BalanceTransaction::query()->where('reference', $reference)->first(); if ($existing) { if ($existing->account_id !== $accountId || $this->toCents((string) $existing->amount) !== -$cents) { - throw new InvalidArgumentException('Ключ операции уже использован с другими параметрами.'); + throw new InvalidArgumentException(__('Ключ операции уже использован с другими параметрами.')); } return $existing; @@ -53,7 +53,7 @@ class BalanceService $current = $this->toCents((string) ($balance->balance ?? '0')); if ($current < $cents) { - throw new InvalidArgumentException('Недостаточно средств.'); + throw new InvalidArgumentException(__('Недостаточно средств.')); } $after = $this->fromCents($current - $cents); @@ -82,7 +82,7 @@ class BalanceService ): BalanceTransaction { $cents = $this->toCents($amount); if ($cents <= 0 || ! in_array($direction, ['credit', 'debit'], true)) { - throw new InvalidArgumentException('Некорректная корректировка баланса.'); + throw new InvalidArgumentException(__('Некорректная корректировка баланса.')); } return DB::connection('vmangos_auth')->transaction(function () use ($accountId, $cents, $direction, $adminAccountId, $adminUsername, $reason): BalanceTransaction { @@ -100,7 +100,7 @@ class BalanceService $delta = $direction === 'credit' ? $cents : -$cents; if ($current + $delta < 0) { - throw new InvalidArgumentException('Недостаточно средств для ручного списания.'); + throw new InvalidArgumentException(__('Недостаточно средств для ручного списания.')); } $after = $this->fromCents($current + $delta); @@ -146,7 +146,7 @@ class BalanceService { $normalized = str_replace(',', '.', trim($amount)); if (! preg_match('/^-?\d+(?:\.\d{1,6})?$/', $normalized)) { - throw new InvalidArgumentException('Некорректная сумма.'); + throw new InvalidArgumentException(__('Некорректная сумма.')); } $negative = str_starts_with($normalized, '-'); diff --git a/app/Services/RobokassaService.php b/app/Services/RobokassaService.php index ad037b3..955bc41 100644 --- a/app/Services/RobokassaService.php +++ b/app/Services/RobokassaService.php @@ -36,13 +36,13 @@ class RobokassaService 'MerchantLogin' => $this->credential($mode, 'login'), 'OutSum' => $amount, 'InvId' => (string) $invoice->id, - 'Description' => 'Пополнение Слёз Элуны', + 'Description' => __('Пополнение Слёз Элуны'), 'SignatureValue' => $this->hash($this->signatureBase( [$this->credential($mode, 'login'), $amount, (string) $invoice->id, $this->credential($mode, 'password1')], $custom, ), $mode), 'Email' => $email, - 'Culture' => 'ru', + 'Culture' => app()->getLocale(), 'Encoding' => 'utf-8', 'IsTest' => $mode === PaymentInvoice::MODE_TEST ? '1' : '0', ...$custom, @@ -58,7 +58,7 @@ class RobokassaService $accountId = $this->value($payload, 'Shp_account'); if (! ctype_digit($invoiceId) || ! ctype_digit($accountId) || $signature === '') { - throw new InvalidArgumentException('Некорректные параметры уведомления Robokassa.'); + throw new InvalidArgumentException(__('Некорректные параметры уведомления Robokassa.')); } $invoiceForSignature = PaymentInvoice::query()->findOrFail((int) $invoiceId); @@ -71,14 +71,14 @@ class RobokassaService ), $mode); if (! hash_equals(strtolower($expected), strtolower($signature))) { - throw new InvalidArgumentException('Некорректная подпись Robokassa.'); + throw new InvalidArgumentException(__('Некорректная подпись Robokassa.')); } return DB::connection('vmangos_auth')->transaction(function () use ($invoiceId, $accountId, $amount): PaymentInvoice { $invoice = PaymentInvoice::query()->lockForUpdate()->findOrFail((int) $invoiceId); if ($invoice->account_id !== (int) $accountId || $this->normalizeAmount((string) $invoice->amount) !== $this->normalizeAmount($amount)) { - throw new InvalidArgumentException('Сумма или получатель платежа не совпадают со счётом.'); + throw new InvalidArgumentException(__('Сумма или получатель платежа не совпадают со счётом.')); } if ($invoice->status === PaymentInvoice::STATUS_PAID) { @@ -128,7 +128,7 @@ class RobokassaService private function normalizeAmount(string $amount): string { if (! is_numeric($amount) || (float) $amount < 0) { - throw new InvalidArgumentException('Некорректная сумма.'); + throw new InvalidArgumentException(__('Некорректная сумма.')); } return number_format((float) $amount, 2, '.', ''); @@ -148,7 +148,7 @@ class RobokassaService private function multiplyAmount(string $amount, int $multiplier): string { if ($multiplier <= 0) { - throw new RuntimeException('Некорректный курс Слёз Элуны.'); + throw new RuntimeException(__('Некорректный курс Слёз Элуны.')); } return number_format((float) $amount * $multiplier, 2, '.', ''); @@ -170,7 +170,7 @@ class RobokassaService { $algorithm = strtolower($this->credential($mode ?? $this->currentMode(), 'hash_algorithm')); if (! in_array($algorithm, hash_algos(), true)) { - throw new RuntimeException('Неизвестный алгоритм подписи Robokassa.'); + throw new RuntimeException(__('Неизвестный алгоритм подписи Robokassa.')); } return hash($algorithm, $value); @@ -192,7 +192,7 @@ class RobokassaService { $mode = (string) config('services.robokassa.mode'); if (! in_array($mode, [PaymentInvoice::MODE_TEST, PaymentInvoice::MODE_PRODUCTION], true)) { - throw new RuntimeException('ROBOKASSA_MODE должен быть test или production.'); + throw new RuntimeException(__('ROBOKASSA_MODE должен быть test или production.')); } return $mode; @@ -206,7 +206,7 @@ class RobokassaService private function assertConfigured(string $mode): void { if (! in_array($mode, [PaymentInvoice::MODE_TEST, PaymentInvoice::MODE_PRODUCTION], true)) { - throw new RuntimeException('Некорректный режим Robokassa.'); + throw new RuntimeException(__('Некорректный режим Robokassa.')); } foreach (['login', 'password1', 'password2'] as $key) { diff --git a/app/Services/SeoService.php b/app/Services/SeoService.php index cf1ce69..41c05d7 100644 --- a/app/Services/SeoService.php +++ b/app/Services/SeoService.php @@ -35,32 +35,34 @@ class SeoService $article = $request->route('news'); $indexable = in_array($route, ['landing.index', 'news.index', 'news.show'], true); $title = match ($route) { - 'landing.index' => "{$name} — PvP сервер WoW Vanilla {$version} {$rate}", - 'news.index' => "Новости сервера WoW {$version} — {$name}", + 'landing.index' => __(':name — PvP сервер WoW Vanilla :version :rate', ['name' => $name, 'version' => $version, 'rate' => $rate]), + 'news.index' => __('Новости сервера WoW :version — :name', ['version' => $version, 'name' => $name]), 'news.show' => $article instanceof News ? "{$article->title} — {$name}" : $name, - 'legal.offer' => "Публичная оферта — {$name}", - 'legal.privacy' => "Политика обработки персональных данных — {$name}", - 'legal.rules' => "Правила проекта — {$name}", - 'login' => "Вход в личный кабинет — {$name}", - 'cabinet.index' => "Личный кабинет — {$name}", - 'admin.accounts.index' => "Аккаунты и инвайты — {$name}", - 'admin.balances.index' => "Слёзы Элуны — {$name}", - 'admin.shop.index' => "Магазин — {$name}", - 'admin.news.index' => "Новости — {$name}", - 'admin.login-screen-news.index' => "Новости WoW — {$name}", + 'legal.offer' => __('Публичная оферта — :name', ['name' => $name]), + 'legal.privacy' => __('Политика обработки персональных данных — :name', ['name' => $name]), + 'legal.rules' => __('Правила проекта — :name', ['name' => $name]), + 'login' => __('Вход в личный кабинет — :name', ['name' => $name]), + 'password.request', 'password.reset' => __('Восстановление пароля — :name', ['name' => $name]), + 'verification.notice', 'verification.verify' => __('Подтверждение email — :name', ['name' => $name]), + 'cabinet.index' => __('Личный кабинет — :name', ['name' => $name]), + 'admin.accounts.index' => __('Аккаунты и инвайты — :name', ['name' => $name]), + 'admin.balances.index' => __('Слёзы Элуны — :name', ['name' => $name]), + 'admin.shop.index' => __('Магазин — :name', ['name' => $name]), + 'admin.news.index' => __('Новости — :name', ['name' => $name]), + 'admin.login-screen-news.index' => __('Новости WoW — :name', ['name' => $name]), default => $name, }; $description = match ($route) { - 'landing.index' => "{$name} — русскоязычный PvP сервер World of Warcraft Vanilla {$version} с рейтами {$rate}. Классический Азерот, подземелья и рейды. Создай аккаунт и начни играть.", - 'news.index' => "Новости PvP сервера WoW Vanilla {$version} {$name}: обновления, события, патчноуты и объявления проекта.", + 'landing.index' => __(':name — русскоязычный PvP сервер World of Warcraft Vanilla :version с рейтами :rate. Классический Азерот, подземелья и рейды. Создай аккаунт и начни играть.', ['name' => $name, 'version' => $version, 'rate' => $rate]), + 'news.index' => __('Новости PvP сервера WoW Vanilla :version :name: обновления, события, патчноуты и объявления проекта.', ['version' => $version, 'name' => $name]), 'news.show' => $article instanceof News ? Str::limit(Str::squish(html_entity_decode(strip_tags($article->body), ENT_QUOTES | ENT_HTML5, 'UTF-8')), 160) : '', - 'legal.offer' => "Публичная оферта о заключении договора возмездного оказания цифровых услуг {$name}.", - 'legal.privacy' => "Политика обработки и защиты данных пользователей {$name}.", - 'legal.rules' => "Запрещённые действия, меры ответственности и порядок обжалования санкций в проекте {$name}.", - 'login' => "Вход в личный кабинет игрового сервера {$name}.", - 'cabinet.index' => "Персонажи, баланс и настройки игрового аккаунта {$name}.", + 'legal.offer' => __('Публичная оферта о заключении договора возмездного оказания цифровых услуг :name.', ['name' => $name]), + 'legal.privacy' => __('Политика обработки и защиты данных пользователей :name.', ['name' => $name]), + 'legal.rules' => __('Запрещённые действия, меры ответственности и порядок обжалования санкций в проекте :name.', ['name' => $name]), + 'login' => __('Вход в личный кабинет игрового сервера :name.', ['name' => $name]), + 'cabinet.index' => __('Персонажи, баланс и настройки игрового аккаунта :name.', ['name' => $name]), default => '', }; $canonical = match ($route) { @@ -71,7 +73,7 @@ class SeoService }; if ($route === 'news.index' && Paginator::resolveCurrentPage() > 1) { - $title .= ' — страница '.Paginator::resolveCurrentPage(); + $title .= __(' — страница :page', ['page' => Paginator::resolveCurrentPage()]); } $image = $this->absoluteUrl($article instanceof News && $article->image_url ? $article->image_url : '/landing.png'); @@ -84,7 +86,7 @@ class SeoService 'url' => $home, 'name' => $name, 'alternateName' => 'Лунный колодец', - 'inLanguage' => 'ru-RU', + 'inLanguage' => app()->getLocale() === 'en' ? 'en-US' : 'ru-RU', ]; $graph[] = [ '@type' => $route === 'news.index' ? 'CollectionPage' : 'WebPage', @@ -92,7 +94,7 @@ class SeoService 'url' => $canonical, 'name' => $title, 'description' => $description, - 'inLanguage' => 'ru-RU', + 'inLanguage' => app()->getLocale() === 'en' ? 'en-US' : 'ru-RU', 'isPartOf' => ['@id' => $home.'#website'], ]; @@ -104,7 +106,7 @@ class SeoService 'image' => $image, 'datePublished' => $article->created_at?->toIso8601String(), 'dateModified' => $article->updated_at?->toIso8601String(), - 'inLanguage' => 'ru-RU', + 'inLanguage' => app()->getLocale() === 'en' ? 'en-US' : 'ru-RU', 'mainEntityOfPage' => ['@id' => $canonical.'#webpage'], 'author' => ['@type' => 'Organization', 'name' => $name, 'url' => $home], 'publisher' => ['@type' => 'Organization', 'name' => $name, 'url' => $home], diff --git a/app/Services/VMangosAccountRegistrar.php b/app/Services/VMangosAccountRegistrar.php index ae54586..9023c56 100644 --- a/app/Services/VMangosAccountRegistrar.php +++ b/app/Services/VMangosAccountRegistrar.php @@ -23,7 +23,7 @@ class VMangosAccountRegistrar $connection = DB::connection(config('moonwell.auth_connection')); - return DB::transaction(fn (): array => $connection->transaction(function () use ( + $result = DB::transaction(fn (): array => $connection->transaction(function () use ( $connection, $normalizedEmail, $normalizedUsername, @@ -56,6 +56,7 @@ class VMangosAccountRegistrar 'name' => $normalizedUsername, 'email' => $normalizedEmail, 'password' => $password, + 'email_verification_required' => true, ]); } catch (QueryException $exception) { if ($this->isDuplicateKeyException($exception)) { @@ -107,6 +108,17 @@ class VMangosAccountRegistrar 'username' => $normalizedUsername, ]; })); + + // Register only after the game DB transaction has finished. If an invite + // transaction is still open on the web DB, defer until it commits too. + DB::afterCommit(function () use ($normalizedUsername): void { + $user = User::where('name', $normalizedUsername)->first(); + if ($user !== null) { + app(AccountEmailService::class)->sendVerification($user); + } + }); + + return $result; } private function accountExists(ConnectionInterface $connection, string $column, string $value): bool diff --git a/app/Support/LocaleFormatting.php b/app/Support/LocaleFormatting.php new file mode 100644 index 0000000..87906f8 --- /dev/null +++ b/app/Support/LocaleFormatting.php @@ -0,0 +1,20 @@ +getLocale() === 'en' + ? number_format($value, $decimals, '.', ',') + : number_format($value, $decimals, ',', ' '); + } + + public static function date(?CarbonInterface $value, bool $seconds = false): ?string + { + return $value?->format((app()->getLocale() === 'en' ? 'M j, Y H:i' : 'd.m.Y H:i').($seconds ? ':s' : '')); + } +} diff --git a/app/Support/VMangosTime.php b/app/Support/VMangosTime.php index ef2c115..a1d61e8 100644 --- a/app/Support/VMangosTime.php +++ b/app/Support/VMangosTime.php @@ -10,7 +10,7 @@ class VMangosTime { // MySQL sessions for vMaNGOS read TIMESTAMP columns in UTC. return $value && ! str_starts_with($value, '0000-00-00') - ? CarbonImmutable::parse($value, 'UTC')->setTimezone(config('moonwell.timezone', 'UTC'))->format('d.m.Y H:i') + ? LocaleFormatting::date(CarbonImmutable::parse($value, 'UTC')->setTimezone(config('moonwell.timezone', 'UTC'))) : '—'; } } diff --git a/app/Support/WowData.php b/app/Support/WowData.php index 59138ba..07d861e 100644 --- a/app/Support/WowData.php +++ b/app/Support/WowData.php @@ -10,53 +10,53 @@ class WowData public static function securityLevels(): array { return [ - 0 => 'Игрок', - 1 => 'Модератор', - 2 => 'Гейммастер', - 3 => 'Администратор', + 0 => __('Игрок'), + 1 => __('Модератор'), + 2 => __('Гейммастер'), + 3 => __('Администратор'), ]; } public static function securityLevelName(int $level): string { - return self::securityLevels()[$level] ?? 'Неизвестно'; + return self::securityLevels()[$level] ?? __('Неизвестно'); } public static function raceName(int $race): string { return [ - 1 => 'Человек', - 2 => 'Орк', - 3 => 'Дворф', - 4 => 'Ночной эльф', - 5 => 'Нежить', - 6 => 'Таурен', - 7 => 'Гном', - 8 => 'Тролль', - ][$race] ?? 'Неизвестно'; + 1 => __('Человек'), + 2 => __('Орк'), + 3 => __('Дворф'), + 4 => __('Ночной эльф'), + 5 => __('Нежить'), + 6 => __('Таурен'), + 7 => __('Гном'), + 8 => __('Тролль'), + ][$race] ?? __('Неизвестно'); } public static function className(int $class): string { return [ - 1 => 'Воин', - 2 => 'Паладин', - 3 => 'Охотник', - 4 => 'Разбойник', - 5 => 'Жрец', - 7 => 'Шаман', - 8 => 'Маг', - 9 => 'Чернокнижник', - 11 => 'Друид', - ][$class] ?? 'Неизвестно'; + 1 => __('Воин'), + 2 => __('Паладин'), + 3 => __('Охотник'), + 4 => __('Разбойник'), + 5 => __('Жрец'), + 7 => __('Шаман'), + 8 => __('Маг'), + 9 => __('Чернокнижник'), + 11 => __('Друид'), + ][$class] ?? __('Неизвестно'); } public static function genderName(int $gender): string { return [ - 0 => 'Мужской', - 1 => 'Женский', - ][$gender] ?? 'Неизвестно'; + 0 => __('Мужской'), + 1 => __('Женский'), + ][$gender] ?? __('Неизвестно'); } public static function formatMoney(int $copper): string @@ -65,7 +65,7 @@ class WowData $silver = intdiv($copper % 10000, 100); $bronze = $copper % 100; - return sprintf('%dз %dс %dм', $gold, $silver, $bronze); + return sprintf(__('%dз %dс %dм'), $gold, $silver, $bronze); } public static function formatPlayedTime(int $seconds): string @@ -75,13 +75,13 @@ class WowData $minutes = intdiv($seconds % 3600, 60); if ($days > 0) { - return sprintf('%dд %dч', $days, $hours); + return sprintf(__('%dд %dч'), $days, $hours); } if ($hours > 0) { - return sprintf('%dч %dм', $hours, $minutes); + return sprintf(__('%dч %dм'), $hours, $minutes); } - return sprintf('%dм', $minutes); + return sprintf(__('%dм'), $minutes); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index daefa63..4176cca 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -5,6 +5,7 @@ use App\Http\Middleware\EnsureGameServer; use App\Http\Middleware\EnsureLauncherRequest; use App\Http\Middleware\ForceJsonResponse; use App\Http\Middleware\HandleInertiaRequests; +use App\Http\Middleware\SetLocale; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -19,6 +20,7 @@ return Application::configure(basePath: dirname(__DIR__)) ) ->withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [ + SetLocale::class, HandleInertiaRequests::class, ]); @@ -32,6 +34,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->api(prepend: [ SetCacheHeaders::class.':no_store', ForceJsonResponse::class, + SetLocale::class, ]); $middleware->alias([ diff --git a/compose.yaml b/compose.yaml index 0b23369..0198bc8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,11 +1,11 @@ services: laravel.test: build: - context: './docker/sail/8.3' + context: './docker/sail/8.5' dockerfile: Dockerfile args: WWWGROUP: '${WWWGROUP:-1000}' - image: 'sail-8.3/app' + image: 'moonwell-sail-8.5/app' restart: unless-stopped extra_hosts: - 'host.docker.internal:host-gateway' diff --git a/composer.json b/composer.json index 80feb6b..367a50f 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "keywords": ["laravel", "framework"], "license": "MIT", "require": { - "php": "^8.2", + "php": "^8.5.12", "inertiajs/inertia-laravel": "^3.1", "laravel/framework": "^12.0", "laravel/passport": "^13.6", @@ -82,7 +82,7 @@ }, "config": { "platform": { - "php": "8.3.30" + "php": "8.5.12" }, "optimize-autoloader": true, "preferred-install": "dist", diff --git a/composer.lock b/composer.lock index ef7ecc5..d67cd25 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9528534655052c637f0b4b19340a6c65", + "content-hash": "f5f0437c732acb0024b7b46bbfd123cf", "packages": [ { "name": "aws/aws-crt-php", @@ -731,12 +731,12 @@ "version": "v7.0.3", "source": { "type": "git", - "url": "https://github.com/firebase/php-jwt.git", + "url": "https://github.com/googleapis/php-jwt.git", "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", "shasum": "" }, @@ -7545,12 +7545,12 @@ "version": "v5.6.3", "source": { "type": "git", - "url": "https://github.com/vlucas/phpdotenv.git", + "url": "https://github.com/phpdotenv/phpdotenv.git", "reference": "955e7815d677a3eaa7075231212f2110983adecc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "url": "https://api.github.com/repos/phpdotenv/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", "reference": "955e7815d677a3eaa7075231212f2110983adecc", "shasum": "" }, @@ -10390,11 +10390,11 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.2" + "php": "^8.5.12" }, "platform-dev": {}, "platform-overrides": { - "php": "8.3.30" + "php": "8.5.12" }, "plugin-api-version": "2.9.0" } diff --git a/config/auth.php b/config/auth.php index 9dd4c0a..791951f 100644 --- a/config/auth.php +++ b/config/auth.php @@ -15,7 +15,7 @@ return [ 'defaults' => [ 'guard' => env('AUTH_GUARD', 'web'), - 'passwords' => env('AUTH_PASSWORD_BROKER', 'game_accounts'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'web_users'), ], /* @@ -65,6 +65,11 @@ return [ */ 'providers' => [ + + 'web_users' => [ + 'driver' => 'eloquent', + 'model' => App\Models\User::class, + ], 'game_accounts' => [ 'driver' => 'vmangos', ], @@ -95,6 +100,12 @@ return [ */ 'passwords' => [ + 'web_users' => [ + 'provider' => 'web_users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], 'game_accounts' => [ 'provider' => 'game_accounts', 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), diff --git a/config/localization.php b/config/localization.php new file mode 100644 index 0000000..38a278b --- /dev/null +++ b/config/localization.php @@ -0,0 +1,9 @@ + ['ru', 'en'], + 'fallback' => 'ru', + // Optional country metadata supplied by your reverse proxy or CDN. + 'country_header' => env('MOONWELL_LOCALE_COUNTRY_HEADER', 'CF-IPCountry'), + 'russian_countries' => ['RU', 'BY', 'KZ', 'KG'], +]; diff --git a/config/mail.php b/config/mail.php index e32e88d..64ca701 100644 --- a/config/mail.php +++ b/config/mail.php @@ -111,7 +111,7 @@ return [ */ 'from' => [ - 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'address' => env('MAIL_FROM_ADDRESS', 'no-reply@moon-well.online'), 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), ], diff --git a/database/migrations/2026_11_06_000001_add_email_verification_required_to_users.php b/database/migrations/2026_11_06_000001_add_email_verification_required_to_users.php new file mode 100644 index 0000000..5c77161 --- /dev/null +++ b/database/migrations/2026_11_06_000001_add_email_verification_required_to_users.php @@ -0,0 +1,21 @@ +boolean('email_verification_required')->default(false); + }); + } + + public function down(): void + { + Schema::table('users', fn (Blueprint $table) => $table->dropColumn('email_verification_required')); + } +}; diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 12f02be..1332af2 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -FROM php:8.3-fpm-bookworm AS php-base +FROM php:8.5.12-fpm-bookworm AS php-base ARG APP_UID=1000 ARG APP_GID=1000 diff --git a/docker/production/php.ini b/docker/production/php.ini index a579488..f2959c0 100644 --- a/docker/production/php.ini +++ b/docker/production/php.ini @@ -8,6 +8,7 @@ max_execution_time = 60 post_max_size = 100M upload_max_filesize = 100M variables_order = EGPCS +session.upload_progress.enabled = Off [Date] date.timezone = UTC diff --git a/docker/sail/8.3/Dockerfile b/docker/sail/8.5/Dockerfile similarity index 79% rename from docker/sail/8.3/Dockerfile rename to docker/sail/8.5/Dockerfile index 1b355c3..882112e 100644 --- a/docker/sail/8.3/Dockerfile +++ b/docker/sail/8.5/Dockerfile @@ -6,6 +6,7 @@ ARG WWWGROUP ARG NODE_VERSION=24 ARG MYSQL_CLIENT="mysql-client" ARG POSTGRES_VERSION=18 +ARG PHP_MIN_VERSION=8.5.12 WORKDIR /var/www/html @@ -27,15 +28,16 @@ RUN apt-get update && apt-get upgrade -y \ && curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \ && echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \ && apt-get update \ - && apt-get install -y libgd3 php8.3-cli php8.3-dev \ - php8.3-pgsql php8.3-sqlite3 php8.3-gd \ - php8.3-curl php8.3-mongodb \ - php8.3-imap php8.3-mysql php8.3-mbstring \ - php8.3-xml php8.3-zip php8.3-bcmath php8.3-soap \ - php8.3-intl php8.3-readline \ - php8.3-ldap \ - php8.3-msgpack php8.3-igbinary php8.3-redis \ - php8.3-memcached php8.3-pcov php8.3-imagick php8.3-xdebug php8.3-swoole \ + && apt-get install -y libgd3 php8.5-cli php8.5-dev \ + php8.5-pgsql php8.5-sqlite3 php8.5-gd \ + php8.5-curl php8.5-mongodb \ + php8.5-imap php8.5-mysql php8.5-mbstring \ + php8.5-xml php8.5-zip php8.5-bcmath php8.5-soap \ + php8.5-intl php8.5-readline \ + php8.5-ldap \ + php8.5-msgpack php8.5-igbinary php8.5-redis \ + php8.5-memcached php8.5-pcov php8.5-imagick php8.5-xdebug \ + && php8.5 -r 'exit(version_compare(PHP_VERSION, $argv[1], ">=") ? 0 : 1);' "$PHP_MIN_VERSION" \ && curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \ && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ @@ -57,11 +59,11 @@ RUN apt-get update && apt-get upgrade -y \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* RUN apt-get update \ - && apt-get install -y nginx php8.3-fpm \ + && apt-get install -y nginx php8.5-fpm \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* -RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.3 +RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.5 RUN userdel -r ubuntu RUN groupadd --force -g $WWWGROUP sail @@ -71,9 +73,9 @@ RUN git config --global --add safe.directory /var/www/html COPY start-container /usr/local/bin/start-container COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf COPY nginx.conf /etc/nginx/nginx.conf -COPY php-fpm-pool.conf /etc/php/8.3/fpm/pool.d/www.conf -COPY php.ini /etc/php/8.3/cli/conf.d/99-sail.ini -COPY php.ini /etc/php/8.3/fpm/conf.d/99-sail.ini +COPY php-fpm-pool.conf /etc/php/8.5/fpm/pool.d/www.conf +COPY php.ini /etc/php/8.5/cli/conf.d/99-sail.ini +COPY php.ini /etc/php/8.5/fpm/conf.d/99-sail.ini RUN chmod +x /usr/local/bin/start-container EXPOSE 80/tcp diff --git a/docker/sail/8.3/nginx.conf b/docker/sail/8.5/nginx.conf similarity index 100% rename from docker/sail/8.3/nginx.conf rename to docker/sail/8.5/nginx.conf diff --git a/docker/sail/8.3/php-fpm-pool.conf b/docker/sail/8.5/php-fpm-pool.conf similarity index 100% rename from docker/sail/8.3/php-fpm-pool.conf rename to docker/sail/8.5/php-fpm-pool.conf diff --git a/docker/sail/8.3/php.ini b/docker/sail/8.5/php.ini similarity index 71% rename from docker/sail/8.3/php.ini rename to docker/sail/8.5/php.ini index 0d8ce9e..3a93393 100644 --- a/docker/sail/8.3/php.ini +++ b/docker/sail/8.5/php.ini @@ -2,4 +2,5 @@ post_max_size = 100M upload_max_filesize = 100M variables_order = EGPCS +session.upload_progress.enabled = Off pcov.directory = . diff --git a/docker/sail/8.3/start-container b/docker/sail/8.5/start-container similarity index 100% rename from docker/sail/8.3/start-container rename to docker/sail/8.5/start-container diff --git a/docker/sail/8.3/supervisord.conf b/docker/sail/8.5/supervisord.conf similarity index 87% rename from docker/sail/8.3/supervisord.conf rename to docker/sail/8.5/supervisord.conf index c4dad31..05ca058 100644 --- a/docker/sail/8.3/supervisord.conf +++ b/docker/sail/8.5/supervisord.conf @@ -5,7 +5,7 @@ logfile=/var/log/supervisor/supervisord.log pidfile=/var/run/supervisord.pid [program:php-fpm] -command=/usr/sbin/php-fpm8.3 --nodaemonize --fpm-config /etc/php/8.3/fpm/php-fpm.conf +command=/usr/sbin/php-fpm8.5 --nodaemonize --fpm-config /etc/php/8.5/fpm/php-fpm.conf user=root priority=10 autostart=true diff --git a/docs/mail-server.md b/docs/mail-server.md index ac717e8..80ee83a 100644 --- a/docs/mail-server.md +++ b/docs/mail-server.md @@ -1,6 +1,6 @@ -# Почта техподдержки +# Почта MoonWell -Для `support@moon-well.online` используется отдельный контейнер +Для `support@moon-well.online` и `no-reply@moon-well.online` используется отдельный контейнер [Stalwart Mail Server](https://stalw.art/). Он предоставляет SMTP, IMAP, антиспам, DKIM и административный интерфейс. Данные и конфигурация хранятся в именованных Docker volumes и переживают пересоздание контейнера. @@ -72,6 +72,28 @@ ssh -L 8081:127.0.0.1:8081 <пользователь>@<сервер> Создай обычный пользовательский аккаунт `support@moon-well.online`. Административный аккаунт нельзя использовать в почтовом клиенте. +Для приложения создай отдельный SMTP-аккаунт `no-reply@moon-well.online` +с правом отправки от этого адреса. Laravel использует его для подтверждения +email и восстановления пароля. Пароль хранится только в `.env` приложения; +в репозиторий его добавлять не нужно. Ящик поддержки остаётся отдельным. + +В панели Stalwart открой `Management → Directory → Accounts` и создай +обычный пользовательский аккаунт (роль User): + +- имя: `no-reply`; +- домен: `moon-well.online`; +- основной email: `no-reply@moon-well.online`; +- отображаемое имя: `MoonWell`; +- пароль: отдельный случайный пароль для SMTP. + +В версиях панели с полем полного логина укажи `no-reply@moon-well.online`. +Названия полей могут отличаться между версиями; итоговый адрес и SMTP-логин +должны быть `no-reply@moon-well.online`. Не выдавай этому аккаунту роль +администратора. Отдельные MX/SPF/DKIM-записи для нового ящика не нужны — +используется уже настроенный домен. + +[Создание аккаунтов в документации Stalwart](https://www.stalw.art/docs/faq/accounts/). + ## DNS после мастера В панели Stalwart открой: @@ -105,26 +127,50 @@ SMTP-аутентификация: включена ## Подключение Laravel -После создания ящика укажи в production `.env`: +После создания `no-reply@moon-well.online` укажи в production `.env`: ```dotenv +APP_URL=https://moon-well.online MAIL_MAILER=smtp MAIL_SCHEME=smtps MAIL_HOST=mail.moon-well.online MAIL_PORT=465 -MAIL_USERNAME=support@moon-well.online +MAIL_USERNAME=no-reply@moon-well.online MAIL_PASSWORD=<пароль ящика> -MAIL_FROM_ADDRESS=support@moon-well.online +MAIL_FROM_ADDRESS=no-reply@moon-well.online MAIL_FROM_NAME="${APP_NAME}" ``` Затем сбрось и пересобери кэш конфигурации: ```bash -docker compose exec -T laravel.test php artisan optimize:clear -docker compose exec -T laravel.test php artisan config:cache +docker compose -f compose.prod.yaml exec -T --user app app php artisan optimize:clear +docker compose -f compose.prod.yaml exec -T --user app app php artisan config:cache ``` +Если используется SSH-туннель к БД ядра, добавь +`-f compose.prod.ssh-tunnel.yaml` к обеим командам. Для Sail используй +`docker compose exec -T laravel.test php artisan …`. + +`MAIL_HOST` — публичное имя с действительным TLS-сертификатом. Приложение +может отправлять письма на опубликованный Stalwart порт 465, даже если его +контейнер находится в другой Docker-сети. Не отключай проверку сертификата +и не подменяй имя на `stalwart` при использовании публичного сертификата. + +При доставке нового кода выполни миграции (`php artisan migrate --force`). +Новые регистрации на сайте и через API лаунчера требуют подтверждения +почты; существующие и импортированные аккаунты сохраняют доступ. +Подтверждение не требует предварительного входа. Повторное письмо доступно +на `/email/verify`, восстановление — на `/forgot-password`. Обе ссылки +действуют 60 минут; ссылка восстановления одноразовая. Пароль меняется +в `web.users`, как при смене пароля в кабинете; игровые SRP-данные остаются +под управлением механизма билетов лаунчера. + +Локально можно оставить `MAIL_MAILER=log`: тогда письма записываются в лог. +Для проверки реальной доставки используй свой аккаунт: зарегистрируй его, +подтверди почту, затем запроси восстановление. В обоих письмах отправитель +должен быть `MoonWell `, а ссылки вести на `APP_URL`. + ## Резервное копирование В бэкап должны попадать оба именованных volume: @@ -150,6 +196,11 @@ Deploy-hook Certbot копирует обновлённые файлы в это Hook устанавливается из `scripts/deploy-mail-certificate.sh`. +Certbot запускает deploy-hook для каждого обновлённого сертификата. Скрипт +проверяет, что сертификат покрывает `mail.moon-well.online` +(`MAIL_TLS_HOSTNAME`), и пропускает сертификаты других сайтов. Иначе +обновление сертификата основного сайта подменит сертификат SMTP/IMAP. + Внешний HTTPS-доступ к панели и JMAP проксируется через Nginx. Готовая конфигурация находится в `ops/nginx-mail.conf`; внутренний HTTP-порт Stalwart остаётся доступен только на `127.0.0.1`. @@ -158,3 +209,12 @@ Hook устанавливается из `scripts/deploy-mail-certificate.sh`. сертификат по умолчанию. Для HTTP-настроек за Nginx также включается `useXForwarded`, чтобы автоматические блокировки применялись к реальному адресу клиента, а не к Docker gateway. + +Если Nginx возвращает 502, а Stalwart работает и пишет +`security.ip-blocked ... remoteIp = `, проверь блокировку +адреса шлюза Docker в `Settings → Security → Blocked IPs`. Сними блокировку +только с этого адреса и добавь именно его в `Allowed IPs`, затем перезагрузи +настройки. Адрес шлюза можно узнать через `docker inspect`. HTTP-порт +Stalwart должен оставаться доступным только на loopback-интерфейсе хоста; +Nginx передаёт реальный адрес клиента в HTTP-заголовках. Не добавляй весь +интернет в разрешённые адреса и не отключай auto-ban. diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..8c3e3be --- /dev/null +++ b/lang/en.json @@ -0,0 +1,645 @@ +{ + "Дата вступления в силу": "Effective date", + "Содержание": "Contents", + "Юридическая информация": "Legal information", + "Новости": "News", + "Публичная оферта": "Public Offer", + "Политика обработки персональных данных": "Personal Data Processing Policy", + "Правила проекта": "Project Rules", + "© 2026 MoonWell · фанатский проект": "© 2026 MoonWell · Fan project", + "MoonWell — главная": "MoonWell — Home", + "Основная навигация": "Main navigation", + "Главная": "Home", + "Кабинет": "Account", + "Войти": "Sign in", + "Админка": "Admin", + "Оферта": "Offer", + "Персональные данные": "Personal data", + "Правила": "Rules", + "Выйти": "Sign out", + "Закрыть меню": "Close menu", + "Открыть меню": "Open menu", + "Мобильная навигация": "Mobile navigation", + "Личный кабинет": "My account", + "Войти в кабинет": "Sign in to your account", + "Выйти из аккаунта": "Sign out of your account", + "Аккаунты": "Accounts", + "Слёзы Элуны": "Tears of Elune", + "Магазин": "Shop", + "Новости WoW": "WoW News", + "Разделы админки": "Admin sections", + "Всего кодов": "Total codes", + "Доступно": "Available", + "Использовано": "Used", + "Управление доступом": "Access management", + "Аккаунты и инвайты": "Accounts and invites", + "Игроки, боты, персонажи и уровни доступа — в одном рабочем пространстве.": "Players, bots, characters and access levels in one workspace.", + "Всего аккаунтов": "Total accounts", + "Инвайты": "Invites", + "Доступ к регистрации": "Registration access", + "Регистрация требует действующий инвайт-код.": "Registration requires a valid invite code.", + "Регистрация сейчас открыта без инвайт-кодов.": "Registration is currently open without invite codes.", + "Новая партия": "New batch", + "Создать коды": "Create codes", + "Количество": "Quantity", + "Создаём…": "Creating…", + "Последние коды": "Latest codes", + "Использован": "Used", + "Свободен": "Available", + "Инвайт-коды ещё не создавались.": "No invite codes have been created yet.", + "Игроки и персонажи": "Players and characters", + "Поиск по логину или email, управление GM-уровнем и просмотр состава аккаунта.": "Search by username or email, manage GM levels and view account characters.", + "Логин или email": "Username or email", + "Найти": "Search", + "Сбросить": "Reset", + "Доступ": "Access", + "Регистрация": "Registration", + "Последний вход": "Last sign-in", + "Персонажи": "Characters", + "Уровень доступа": "Access level", + "Сохраняем…": "Saving…", + "Сохранить": "Save", + "Скрыть персонажей": "Hide characters", + "Персонажи · {0}": "Characters · {0}", + "ур.": "lvl", + "Онлайн": "Online", + "Оффлайн": "Offline", + "На аккаунте пока нет персонажей.": "This account has no characters yet.", + "Боты не найдены": "No bots found", + "Игроки не найдены": "No players found", + "Измени поисковый запрос или переключись на другую вкладку.": "Change your search or switch to another tab.", + "Финансы": "Finances", + "Ручные корректировки и полный журнал движения виртуальной валюты.": "Manual adjustments and a complete virtual currency transaction log.", + "Операций в выборке": "Transactions in selection", + "Новая операция": "New transaction", + "Изменить баланс": "Adjust balance", + "ID или логин аккаунта": "Account ID or username", + "Операция": "Transaction", + "Начислить": "Credit", + "Снять": "Debit", + "Причина": "Reason", + "Компенсация, возврат, нарушение…": "Compensation, refund, violation…", + "Проводим…": "Processing…", + "Провести операцию": "Submit transaction", + "Контроль": "Controls", + "Правила операций": "Transaction rules", + "Баланс аккаунта не может стать отрицательным.": "An account balance cannot become negative.", + "В журнале сохраняются администратор, причина и итоговое значение.": "The log records the administrator, reason and resulting balance.", + "Ошибочная операция исправляется отдельной обратной корректировкой.": "An incorrect transaction is corrected with a separate reversing adjustment.", + "Аудит": "Audit", + "Последние транзакции": "Recent transactions", + "Логин или ID аккаунта": "Username or account ID", + "Администратор:": "Administrator:", + "Баланс:": "Balance:", + "Транзакции не найдены.": "No transactions found.", + "Название": "Name", + "Иконка клиента": "Client icon", + "Требуемый ранг": "Required rank", + "Флаги": "Flags", + "Категория включена": "Category enabled", + "Порядок вывода": "Display order", + "Заменить картинку": "Replace image", + "Картинка": "Image", + "Оставь пустым, чтобы сохранить текущую. Максимум 10 МБ.": "Leave empty to keep the current image. Maximum 10 MB.", + "Максимум 10 МБ. Необязательно.": "Maximum 10 MB. Optional.", + "Категории": "Categories", + "Можно выбрать несколько с Ctrl/Cmd.": "Hold Ctrl/Cmd to select multiple items.", + "Тип": "Type", + "Название подсказки": "Tooltip title", + "Тип подсказки": "Tooltip type", + "item или spell": "item or spell", + "Текст подсказки": "Tooltip text", + "Цена, Слёзы Элуны": "Price, Tears of Elune", + "Скидка, %": "Discount, %", + "Награды · 8 слотов": "Rewards · 8 slots", + "Награда": "Reward", + "Пометка «Новинка»": "Mark as New", + "Позиция включена": "Item enabled", + "Всего": "Total", + "Опубликовано": "Published", + "Черновики": "Drafts", + "Поддерживаются WoW-коды цветов |cFF...|r, иконки |T...|t, переносы строк и обычный текст.": "Supports WoW color codes |cFF...|r, icons |T...|t, line breaks and plain text.", + "Название в админке": "Admin name", + "Сразу публиковать эту запись": "Publish this entry immediately", + "Текст для клиента": "Client text", + "Добавить запись": "Add entry", + "Удалить запись": "Delete entry", + "Удалить эту запись из фида?": "Delete this entry from the feed?", + "Показывать в публичном фиде": "Show in public feed", + "Текст блока": "Block text", + "Сохранить запись": "Save entry", + "|TInterface\\ICONS\\INV_Misc_QuestionMark:25:25:0:-5|t |cFF00D1FFКлассический Азерот|r |cFFA6D8FF- Клиент 1.12.1 (build 5875)|r |cFFA6D8FF- Собери отряд и отправляйся в приключение|r": "|TInterface\\ICONS\\INV_Misc_QuestionMark:25:25:0:-5|t |cFF00D1FFClassic Azeroth|r |cFFA6D8FF- Client 1.12.1 (build 5875)|r |cFFA6D8FF- Gather your party and set out on an adventure|r", + "Клиент 1.12.1": "Client 1.12.1", + "Новости логин-экрана WoW": "WoW login screen news", + "Raw-текст с игровой разметкой и готовый публичный SERVERALERT-фид.": "Raw text with in-game markup and a ready-to-use public SERVERALERT feed.", + "Публичная ссылка": "Public link", + "Фид для клиента": "Client feed", + "Ссылка доступна без авторизации и всегда возвращает plain text.": "The link is accessible without signing in and always returns plain text.", + "Открыть фид": "Open feed", + "Итоговый ответ": "Final response", + "Превью сервера": "Server preview", + "Новая запись": "New entry", + "Добавить новость": "Add news", + "Шаблон": "Template", + "Пример WoW-блока": "Sample WoW block", + "Префикс SERVERALERT добавляется автоматически.": "The SERVERALERT prefix is added automatically.", + "Текущие записи": "Current entries", + "Редактирование и публикация": "Editing and publishing", + "Опубликованные записи попадают в фид по порядку sort_order.": "Published entries appear in the feed in sort_order order.", + "Порядок:": "Order:", + "Черновик": "Draft", + "Записей пока нет.": "No entries yet.", + "Заголовок": "Title", + "Сразу опубликовать": "Publish immediately", + "Текст новости": "News text", + "Удалить": "Delete", + "Удалить эту новость?": "Delete this news article?", + "Сохранить новость": "Save news", + "Контент": "Content", + "Публикации для лендинга и лаунчера с изображениями из S3-хранилища.": "Posts for the website and launcher with images from S3 storage.", + "Редактирование": "Editing", + "Записи отсортированы по полю порядка вывода.": "Entries are sorted by display order.", + "Новостей пока нет": "No news yet", + "Добавь первую публикацию.": "Add your first post.", + "Позиции": "Items", + "Активные": "Active", + "Игровой сервер": "Game server", + "Категории, товары, награды и цены из базы игрового модуля.": "Categories, items, rewards and prices from the game module database.", + "Позиций": "Items", + "Глобальная доступность": "Global availability", + "Магазин и пополнение": "Shop and balance deposits", + "При выключении игровой магазин становится недоступен, а форма пополнения скрывается в личном кабинете.": "Disabling this makes the in-game shop unavailable and hides the deposit form in user accounts.", + "Доступны": "Available", + "Выключены": "Disabled", + "Изменения записываются прямо в базу": "Changes are written directly to the database", + ". После сохранения перезагрузи данные магазина командой модуля или перезапусти worldserver.": ". After saving, reload shop data using the module command or restart worldserver.", + "Навигация магазина": "Shop navigation", + "Новая категория": "New category", + "Создать категорию": "Create category", + "Контент магазина": "Shop content", + "Новая позиция": "New item", + "Создать позицию": "Create item", + "Сначала создай категорию.": "Create a category first.", + "Включена": "Enabled", + "Отключена": "Disabled", + "позиций": "items", + "Сохранить категорию": "Save category", + "Удалить категорию и её связи с товарами?": "Delete this category and its item associations?", + "Удалить категорию": "Delete category", + "Категорий пока нет.": "No categories yet.", + "Товары и услуги": "Products and services", + "Без категории": "Uncategorized", + "· тип": "· type", + "Слёз Элуны": "Tears of Elune", + "Сохранить позицию": "Save item", + "Удалить позицию магазина?": "Delete this shop item?", + "Удалить позицию": "Delete item", + "Позиций пока нет.": "No items yet.", + "Восстановление доступа": "Account recovery", + "Твоё приключение продолжается": "Your adventure continues", + "Забытый пароль — лишь короткая остановка на пути. Укажи почту аккаунта, и мы поможем вернуться в Азерот.": "A forgotten password is just a brief pause along the way. Enter your account email and we will help you return to Azeroth.", + "Забыл пароль?": "Forgot password?", + "Пришлём ссылку для создания нового пароля на email, указанный при регистрации.": "We will send a password reset link to the email address you used to register.", + "Отправить ссылку": "Send reset link", + "Укажи почту своего аккаунта": "Enter your account email", + "Открой письмо от MoonWell": "Open the email from MoonWell", + "Задай новый пароль и возвращайся": "Set a new password and return", + "Безопасность аккаунта": "Account security", + "Новый ключ к твоему миру": "A new key to your world", + "Выбери новый пароль для сайта и лаунчера. Твои герои и прогресс будут ждать тебя на прежнем месте.": "Choose a new password for the website and launcher. Your heroes and progress will be waiting right where you left them.", + "Новый пароль": "New password", + "От 8 до 16 символов, хотя бы одна латинская буква и одна цифра.": "Use 8–16 characters, including at least one Latin letter and one number.", + "Сохранить пароль": "Save password", + "Придумай новый пароль": "Choose a new password", + "Повтори его для подтверждения": "Enter it again to confirm", + "Войди на сайт или в лаунчер": "Sign in to the website or launcher", + "Добро пожаловать в MoonWell": "Welcome to MoonWell", + "Один шаг до приключения": "One step away from adventure", + "Подтверди свою почту, чтобы открыть вход в личный кабинет и лаунчер. Она также поможет восстановить доступ к аккаунту.": "Verify your email to sign in to your account and launcher. It will also help you recover access to your account.", + "Проверь свою почту": "Check your email", + "Открой письмо от no-reply@moon-well.online и нажми «Подтвердить email». Ссылка действует 60 минут.": "Open the email from no-reply@moon-well.online and click Verify email. The link is valid for 60 minutes.", + "Отправить письмо повторно": "Resend verification email", + "Подтверди email по ссылке": "Verify your email using the link", + "Войди в аккаунт и начни играть": "Sign in and start playing", + "Всё готово": "You are all set", + "Азерот ждёт тебя": "Azeroth awaits", + "Почта подтверждена. Теперь можно войти в личный кабинет и лаунчер с логином и паролем, которые ты указал при регистрации.": "Your email is verified. You can now sign in to your account and launcher using the username and password you chose during registration.", + "Email подтверждён": "Email verified", + "Добро пожаловать в MoonWell. Пусть лунный свет освещает твой путь.": "Welcome to MoonWell. May the moonlight guide your path.", + "Почта подтверждена": "Email verified", + "Аккаунт готов к входу": "Your account is ready", + "Приключение начинается": "The adventure begins", + "Нужна новая ссылка": "A new link is needed", + "Продолжим твой путь": "Let us continue your journey", + "Срок действия ссылки истёк или она была изменена. Запроси новое письмо, чтобы подтвердить почту.": "This link has expired or was changed. Request another email to verify your address.", + "Ссылка недействительна": "Invalid link", + "Вернись на страницу подтверждения и укажи email своего аккаунта. Мы пришлём новую ссылку.": "Return to the verification page and enter your account email. We will send you a new link.", + "Запроси новое письмо": "Request a new email", + "Используй ссылку из письма": "Use the link in the email", + "Возвращайся в MoonWell": "Return to MoonWell", + "Твой аккаунт": "Your account", + "Email аккаунта": "Account email", + "8–16 символов": "8–16 characters", + "Повтори пароль": "Confirm password", + "Ещё раз новый пароль": "Enter the new password again", + "Отправляем…": "Sending…", + "Повторить через {0} с": "Try again in {0} s", + "Не видишь письмо? Проверь папку «Спам» и правильность адреса. Повторная отправка доступна раз в минуту.": "Cannot find the email? Check your spam folder and email address. You can request another email once a minute.", + "Запросить новую ссылку": "Request a new link", + "В личный кабинет": "Go to your account", + "Войти в аккаунт": "Sign in to your account", + "Запросить новое письмо": "Request another email", + "← Вернуться ко входу": "← Back to sign-in", + "На главную MoonWell": "MoonWell home", + "Вернись к своему приключению": "Return to your adventure", + "Используй логин и пароль игрового аккаунта. Здесь тебя ждут персонажи, баланс, настройки безопасности и лаунчер.": "Use your game account username and password. Find your characters, balance, security settings and launcher here.", + "Все персонажи аккаунта в одной сводке": "All your account characters in one overview", + "Пополнение Слёз Элуны и история операций": "Tears of Elune deposits and transaction history", + "Смена игрового пароля без ручных команд": "Change your password without manual commands", + "Авторизация": "Sign in", + "Вход выполняется по игровым учётным данным.": "Sign in using your account credentials.", + "Логин": "Username", + "Пароль": "Password", + "Твой пароль": "Your password", + "Входим…": "Signing in…", + "Не пришло письмо подтверждения?": "Did not receive a verification email?", + "← Вернуться на главную": "← Back to home", + "Обзор": "Overview", + "Безопасность": "Security", + "С возвращением,": "Welcome back,", + "Твой аккаунт, герои и всё необходимое для следующего путешествия.": "Your account, heroes and everything you need for your next journey.", + "Статус аккаунта": "Account status", + "Навигация": "Navigation", + "Баланс": "Balance", + "Данные аккаунта": "Account details", + "Открыть админку": "Open admin panel", + "Персонажей": "Characters", + "Лаунчер": "Launcher", + "Врата в Азерот": "Your gateway to Azeroth", + "Лаунчер автоматически скачает клиент, настроит realmlist и установит обновления.": "The launcher downloads the client, configures your realmlist and installs updates automatically.", + "Скачать лаунчер": "Download launcher", + "Мои персонажи": "My characters", + "Герои аккаунта": "Account heroes", + "герой": "hero", + "героев": "heroes", + "Пол": "Gender", + "Золото": "Gold", + "Время в игре": "Time played", + "Персонажей пока нет": "No characters yet", + "Создай первого героя в игре — он появится здесь автоматически.": "Create your first hero in the game and they will appear here automatically.", + "Баланс и операции": "Balance and transactions", + "Доступно сейчас": "Available now", + "Сумма пополнения, ₽": "Deposit amount, ₽", + "Пополнить": "Add funds", + "От": "From", + "до": "to", + "Последние операции": "Recent transactions", + "Операций пока нет.": "No transactions yet.", + "Сменить пароль": "Change password", + "Защити свой аккаунт": "Protect your account", + "После обновления новый пароль сразу начнёт действовать и в игре, и в личном кабинете.": "Your new password will work immediately on the website and in the launcher.", + "От 8 до 16 символов": "Between 8 and 16 characters", + "Хотя бы одна буква и одна цифра": "At least one letter and one number", + "Не используй пароль от почты": "Do not use your email password", + "Текущий пароль": "Current password", + "Повтори новый пароль": "Confirm new password", + "Обновляем…": "Updating…", + "Обновить пароль": "Update password", + "Что такое MoonWell": "What is MoonWell?", + "Эпоха за эпохой": "Era by era", + "MoonWell — русскоязычный PvP сервер World of Warcraft Vanilla": "MoonWell is a Russian-speaking World of Warcraft Vanilla PvP server", + ". Развивай героя до 60-го уровня и исследуй классический Азерот: оригинальные зоны, подземелья и рейды.": ". Develop your hero to level 60 and explore classic Azeroth: its original zones, dungeons and raids.", + "Исследуй опасные земли, готовься к первым рейдам и возвращайся к приключению тогда, когда тебе удобно. Здесь не нужно подстраиваться под общий прогресс сервера.": "Explore dangerous lands, prepare for your first raids and return to adventure whenever it suits you. You do not have to keep up with the server's overall progression.", + "«Исследуй классический Азерот в одиночку или собери небольшой отряд для приключений».": "“Explore classic Azeroth on your own or gather a small party for adventure.”", + "— команда MoonWell": "— The MoonWell team", + "Концепт-арт: вход в Лунный колодец, туман и силуэт ночного эльфа": "Concept art: a moonwell entrance, mist and the silhouette of a night elf", + "Особенности сервера": "Server features", + "Системы MoonWell": "MoonWell systems", + "Классический Азерот и планы развития MoonWell.": "Classic Azeroth and MoonWell's development plans.", + "Уровень за уровнем": "Level by level", + "Твой герой · Aranthel, Хранитель рощи · 47 ур.": "Your hero · Aranthel, Keeper of the Grove · Level 47", + "Простая установка": "Easy installation", + "Твоя история начинается здесь": "Your story begins here", + "Лаунчер сам скачает, установит и обновит игровой клиент. Тебе не придётся вручную ставить патчи или менять файлы.": "The launcher downloads, installs and updates the game client for you. No need to apply patches or change files manually.", + "Скачай лаунчер": "Download the launcher", + "Войди или создай аккаунт": "Sign in or create an account", + "Нажми «Играть» — остальное сделает лаунчер": "Click Play and let the launcher do the rest", + "Скачай лаунчер для Windows": "Download the Windows launcher", + "macOS — в разработке": "macOS — In development", + "v 1.4.2 · автоматическая установка и обновления": "v 1.4.2 · Automatic installation and updates", + "Сервер World of Warcraft 1.12.1. Исследуй классический Азерот, развивай героя до 60-го уровня и играй вместе с друзьями.": "A World of Warcraft 1.12.1 server. Explore classic Azeroth, develop your hero to level 60 and play with friends.", + "Сервер": "Server", + "Статус реалмов": "Realm status", + "Скачать клиент": "Download client", + "Сообщество": "Community", + "ВКонтакте": "VK", + "Поддержка": "Support", + "© 2026 MoonWell · фанатский проект · не связан с Blizzard Entertainment": "© 2026 MoonWell · Fan project · Not affiliated with Blizzard Entertainment", + "Сделано с лунным светом": "Made with moonlight", + "MoonWell · PvP сервер WoW Vanilla": "MoonWell · WoW Vanilla PvP server", + "История Азерота в твоём темпе": "The story of Azeroth at your own pace", + "— Лунный колодец —": "— The Moonwell —", + "Вернись в классический Азерот — в одиночку или с небольшим отрядом.": "Return to classic Azeroth, on your own or with a small party.", + "Личный прогресс": "Personal progression", + "Одиночные подземелья": "Solo dungeons", + "Режим «Предатель»": "Traitor mode", + "Мифик+ без предела": "Mythic+ without limits", + "Начни играть": "Start playing", + "Узнай больше": "Learn more", + "Автоматическая установка и обновления": "Automatic installation and updates", + "Прокрути вниз": "Scroll down", + "Используй латиницу и цифры. Регистр символов при входе не имеет значения.": "Use Latin letters and numbers. Usernames are not case-sensitive.", + "Регистрация доступна только по действующему инвайт-коду.": "Registration is available only with a valid invite code.", + "Только латиница и цифры, 3–16 символов.": "Latin letters and numbers only, 3–16 characters.", + "Похоже на некорректный email.": "This does not look like a valid email address.", + "Код из букв, цифр и тире, 6-32 символа.": "Letters, numbers and hyphens, 6–32 characters.", + "Минимум 8 символов.": "At least 8 characters.", + "Не более 16 символов.": "No more than 16 characters.", + "Должна быть хотя бы одна буква.": "Include at least one letter.", + "Должна быть хотя бы одна цифра.": "Include at least one number.", + "Пароли не совпадают.": "Passwords do not match.", + "Добро пожаловать": "Welcome", + "Аккаунт создан": "Account created", + "Подтверди email по ссылке в письме от MoonWell, чтобы войти в аккаунт и лаунчер.": "Verify your email using the link in the MoonWell email to sign in to your account and launcher.", + "Войди в личный кабинет": "Sign in to your account", + "Создай игровой аккаунт": "Create your game account", + "Укажи инвайт-код, логин, почту и пароль": "Enter your invite code, username, email and password", + "Укажи логин, почту и пароль": "Enter your username, email and password", + ". Затем подтверди email по ссылке из письма — и начинай приключение.": ". Then verify your email using the link in the message and begin your adventure.", + "Закрытый бета-доступ": "Closed beta access", + "Пока идёт закрытый этап: регистрация только по инвайт-кодам. Код можно получить у друзей-игроков или в нашем": "During the closed beta, registration is by invitation only. Get a code from friends who play or from our", + "Telegram-сообществе": "Telegram community", + "Инвайт-код": "Invite code", + "Повтор пароля": "Confirm password", + "Я принимаю условия документов:": "I accept the terms of the following documents:", + "Я принимаю правила проекта и согласен на обработку данных для создания игрового аккаунта.": "I accept the project rules and consent to the processing of my data to create a game account.", + "Создаём аккаунт…": "Creating account…", + "Создай аккаунт": "Create account", + "Войди в кабинет": "Sign in to your account", + "Играй": "Play", + "Хроники сервера": "Server chronicles", + "Новости и патчноуты": "News and patch notes", + "Все новости": "All news", + "Прочитать новость «{0}»": "Read “{0}”", + "Читать полностью": "Read full article", + "Пока новостей нет": "No news yet", + "Хроники сервера ещё не открыли первую страницу. Загляни позже — здесь появятся заметки об обновлениях, события и дневники разработки.": "The server chronicles have yet to open their first page. Come back later for updates, events and development diaries.", + "Игроков": "Players", + "Аптайм": "Uptime", + "Пинг": "Latency", + "Фракции": "Factions", + "О сервере": "About", + "Особенности": "Features", + "Главная особенность": "Main feature", + "Исследуй классические зоны Азерота и развивай героя до 60-го уровня в своём темпе.": "Explore classic Azeroth and develop your hero to level 60 at your own pace.", + "Классические подземелья": "Classic dungeons", + "Собери отряд и отправляйся в подземелья оригинального World of Warcraft.": "Gather your party and venture into the dungeons of the original World of Warcraft.", + "В разработке": "In development", + "Создай персонажа Альянса на стороне Орды или персонажа Орды на стороне Альянса. Тебя ждут отдельные сюжеты, персонажи и репутации.": "Create an Alliance character on the Horde side, or a Horde character on the Alliance side. Discover separate stories, characters and reputations.", + "Ликантропия": "Lycanthropy", + "Заразись ликантропией, сопротивляйся ей или прими новую форму. Выбор определит твой путь.": "Become infected with lycanthropy, resist it or embrace your new form. Your choice will shape your path.", + "Рейды Азерота": "Raids of Azeroth", + "Собери союзников и пройди рейды классического World of Warcraft.": "Gather your allies and complete classic World of Warcraft raids.", + "Боевой пропуск · магазин наград": "Battle pass · Reward shop", + "Выполняй ежедневные и еженедельные задания, получай награды за вход и открывай косметические предметы.": "Complete daily and weekly quests, earn login rewards and unlock cosmetic items.", + "Коллекции": "Collections", + "Собирай питомцев, ездовых животных и облики для трансмогрификации в одном журнале с фильтрами и отметками прогресса.": "Keep pets, mounts and transmog appearances in one journal, with filters and progress tracking.", + "Отдельный проект": "Separate project", + "Участвуй в аркадных гонках с персонажами сервера. Мини-игра находится в разработке.": "Race with the server's characters in an arcade kart game. This minigame is in development.", + "Первые шаги": "First steps", + "Классика": "Classic", + "Путь героя": "A hero's journey", + "Высокие уровни": "High levels", + "Защита данных": "Data protection", + "Редакция от": "Revised on", + "Правила MoonWell": "MoonWell Rules", + "Назад": "Previous", + "Вперёд": "Next", + "Новости и обновления": "News and updates", + "События MoonWell, патчноуты, дневники разработки и важные объявления проекта.": "MoonWell events, patch notes, development diaries and important project announcements.", + "Хроники пока пусты": "The chronicles are empty", + "Первая новость появится здесь после публикации.": "The first news article will appear here after publication.", + "Вернуться на главную": "Back to home", + "Страницы новостей": "News pages", + "Вернуться к хроникам сервера": "Back to server chronicles", + "Продолжить чтение": "Continue reading", + "Другие новости": "More news", + "Читать": "Read", + "Язык сайта": "Site language", + "Авто": "Auto", + "Укажи уровень доступа.": "Specify an access level.", + "Уровень доступа должен быть числом.": "The access level must be a number.", + "Выбран недопустимый уровень доступа.": "The selected access level is invalid.", + "Укажи количество инвайт-кодов.": "Specify how many invite codes to create.", + "Количество должно быть числом.": "The quantity must be a number.", + "Нужно создать минимум один инвайт-код.": "Create at least one invite code.", + "За один раз можно создать не больше 50 инвайт-кодов.": "You can create no more than 50 invite codes at a time.", + "Ссылка недействительна. Запроси новое письмо.": "This link is invalid. Request a new email.", + "Укажи логин аккаунта.": "Enter your account username.", + "Логин должен содержать минимум 3 символа.": "Your username must contain at least 3 characters.", + "Логин не должен быть длиннее 16 символов.": "Your username must not exceed 16 characters.", + "Логин может содержать только латинские буквы и цифры.": "Your username may contain only Latin letters and numbers.", + "Укажи пароль.": "Enter a password.", + "Пароль должен содержать минимум 3 символа.": "Your password must contain at least 3 characters.", + "Пароль не должен быть длиннее 255 символов.": "Your password must not exceed 255 characters.", + "Логин должен быть строкой.": "Your username must be a string.", + "Укажи email для аккаунта.": "Enter an email address for your account.", + "Email должен быть строкой.": "The email must be a string.", + "Укажи корректный email адрес.": "Enter a valid email address.", + "Email не должен быть длиннее 255 символов.": "The email must not exceed 255 characters.", + "Укажи инвайт-код.": "Enter an invite code.", + "Инвайт-код должен быть строкой.": "The invite code must be a string.", + "Инвайт-код выглядит слишком коротким.": "The invite code appears to be too short.", + "Инвайт-код выглядит слишком длинным.": "The invite code appears to be too long.", + "Пароль должен быть строкой.": "Your password must be a string.", + "Пароль должен содержать минимум 8 символов.": "Your password must contain at least 8 characters.", + "Пароль не должен быть длиннее 16 символов.": "Your password must not exceed 16 characters.", + "Подтверждение пароля не совпадает.": "The password confirmation does not match.", + "Пароль должен содержать хотя бы одну букву и одну цифру.": "Your password must contain at least one letter and one number.", + "Нужно принять правила сервера и пользовательское соглашение.": "You must accept the server rules and user agreement.", + "логин аккаунта": "account username", + "инвайт-код": "invite code", + "пароль": "password", + "правила": "rules", + "Укажи название новости для админки.": "Enter an admin title for this news entry.", + "Название новости не должно быть длиннее 120 символов.": "The news title must not exceed 120 characters.", + "Заполни текст новости для логин-экрана.": "Enter the login screen news text.", + "Текст новости слишком длинный.": "The news text is too long.", + "Укажи порядок вывода.": "Specify the display order.", + "Порядок вывода должен быть числом.": "The display order must be a number.", + "Порядок вывода не может быть отрицательным.": "The display order cannot be negative.", + "Порядок вывода слишком большой.": "The display order is too large.", + "Укажи заголовок новости.": "Enter a news title.", + "Заголовок не должен быть длиннее 120 символов.": "The title must not exceed 120 characters.", + "Заполни текст новости.": "Enter the news text.", + "Файл должен быть изображением.": "The file must be an image.", + "Изображение не должно превышать 10 МБ.": "The image must not exceed 10 MB.", + "Укажи email аккаунта.": "Enter your account email.", + "Укажи корректный email.": "Enter a valid email address.", + "Укажи текущий пароль.": "Enter your current password.", + "Текущий пароль не должен быть длиннее 255 символов.": "Your current password must not exceed 255 characters.", + "Укажи новый пароль.": "Enter a new password.", + "Новый пароль должен содержать минимум 8 символов.": "Your new password must contain at least 8 characters.", + "Новый пароль не должен быть длиннее 16 символов.": "Your new password must not exceed 16 characters.", + "Подтверждение нового пароля не совпадает.": "The new password confirmation does not match.", + "Новый пароль должен содержать хотя бы одну букву и одну цифру.": "Your new password must contain at least one letter and one number.", + "Пополнение временно недоступно.": "Deposits are temporarily unavailable.", + "Пополнение принято. Слёзы Элуны скоро появятся на счёте.": "Payment accepted. Your Tears of Elune will be credited shortly.", + "Оплата отменена или не была завершена.": "Payment was cancelled or not completed.", + "Заблокирован": "Locked", + "Активен": "Active", + "Пополнение": "Deposit", + "Начисление": "Credit", + "Списание": "Debit", + "Покупка в игре": "In-game purchase", + "Текущий пароль указан неверно.": "Your current password is incorrect.", + "Пароль успешно обновлён.": "Your password has been updated.", + "Не удалось получить ссылку на клиент. Попробуй ещё раз позже.": "Could not obtain a client download link. Please try again later.", + "Не удалось получить список реалмов.": "Could not retrieve the realm list.", + "Манифест не найден.": "Manifest not found.", + "Недопустимый путь.": "Invalid path.", + "Файл не найден в манифесте.": "File not found in the manifest.", + "Некорректный ключ файла в манифесте.": "Invalid file key in the manifest.", + "Файл не найден.": "File not found.", + "Неверный логин или пароль.": "Incorrect username or password.", + "Подтверди email, чтобы войти в аккаунт.": "Verify your email to sign in to your account.", + "Не удалось создать аккаунт. Попробуй снова позже.": "Could not create your account. Please try again later.", + "Игровой аккаунт успешно создан.": "Your game account has been created.", + "Не удалось создать аккаунт. Проверь настройки сервера и попробуй снова.": "Could not create your account. Check the server settings and try again.", + "Аккаунт %s создан. Подтверди email по ссылке в письме. Если письмо не пришло, запроси его повторно ниже.": "Account %s has been created. Verify your email using the link in the message. If the email does not arrive, request another one below.", + "Предмет": "Item", + "Маунт": "Mount", + "Питомец": "Pet", + "Бафф": "Buff", + "Услуга персонажа": "Character service", + "Буст": "Boost", + "Титул": "Title", + "Магазин и пополнение включены.": "The shop and deposits are enabled.", + "Магазин и пополнение выключены.": "The shop and deposits are disabled.", + "Категория создана.": "Category created.", + "Категория обновлена.": "Category updated.", + "Категория удалена.": "Category deleted.", + "Товар создан.": "Item created.", + "Товар обновлён.": "Item updated.", + "Товар удалён.": "Item deleted.", + "Аккаунт удалён": "Account deleted", + "Начисление администратором": "Administrator credit", + "Списание администратором": "Administrator debit", + "Игровой аккаунт не найден.": "Game account not found.", + "Счёт %s изменён. Теперь на нём %s Слёз Элуны.": "Account %s has been adjusted. Its balance is now %s Tears of Elune.", + "Создано инвайт-кодов: %d.": "Invite codes created: %d.", + "Новость добавлена.": "News article added.", + "Новость обновлена.": "News article updated.", + "Новость удалена.": "News article deleted.", + "email не указан": "email not specified", + "ещё не использован": "not used yet", + "Игроки": "Players", + "Боты": "Bots", + "Уровень доступа обновлён.": "Access level updated.", + "Новость для логин-экрана добавлена.": "Login screen news entry added.", + "Новость для логин-экрана обновлена.": "Login screen news entry updated.", + "Новость для логин-экрана удалена.": "Login screen news entry deleted.", + "Не удалось отправить письмо. Попробуй ещё раз через минуту.": "Could not send the email. Please try again in a minute.", + "Если аккаунт с такой почтой существует, письмо со ссылкой для восстановления придёт в течение нескольких минут. Проверь также папку «Спам».": "If an account with that email exists, a password reset link will arrive within a few minutes. Please also check your spam folder.", + "Ссылка недействительна или срок её действия истёк. Запроси новое письмо.": "This link is invalid or has expired. Request a new email.", + "Пароль обновлён. Войди в аккаунт с новым паролем.": "Your password has been updated. Sign in with your new password.", + "Если этот email ожидает подтверждения, мы отправим письмо. Повторный запрос доступен через минуту. Проверь также папку «Спам».": "If that email is awaiting verification, we will send a message. You can try again in one minute. Please also check your spam folder.", + "Сеанс завершён. Можно войти снова.": "You have signed out. You can sign in again.", + "Недостаточно прав для доступа к админке.": "You do not have permission to access the admin panel.", + "Неверный токен игрового сервера.": "Invalid game server token.", + "Такой игровой аккаунт уже существует.": "This game account already exists.", + "На этот email уже зарегистрирован аккаунт.": "An account is already registered with this email.", + "Укажи корректный инвайт-код.": "Enter a valid invite code.", + "Этот инвайт-код уже использован.": "This invite code has already been used.", + "Подтверди email — MoonWell": "Verify your email — MoonWell", + "Ещё один шаг до Азерота": "One more step to Azeroth", + "Подтверди свой email": "Verify your email", + "Добро пожаловать в MoonWell! Подтверди почту, чтобы войти в личный кабинет и лаунчер.": "Welcome to MoonWell! Verify your email to sign in to your account and launcher.", + "Подтвердить email": "Verify email", + "Ссылка действует 60 минут. Новое письмо можно запросить на странице подтверждения.": "This link is valid for 60 minutes. You can request another email on the verification page.", + "Если ты не создавал аккаунт MoonWell, просто проигнорируй это письмо.": "If you did not create a MoonWell account, simply ignore this email.", + "Восстановление пароля — MoonWell": "Reset your password — MoonWell", + "Возвращение в MoonWell": "Return to MoonWell", + "Задай новый пароль": "Set a new password", + "Мы получили запрос на восстановление доступа к твоему аккаунту. Нажми кнопку, чтобы задать новый пароль для сайта и лаунчера.": "We received a request to recover access to your account. Click the button to set a new password for the website and launcher.", + "Восстановить пароль": "Reset password", + "Ссылка действует 60 минут и может быть использована только один раз.": "This link is valid for 60 minutes and can only be used once.", + "Если ты не запрашивал восстановление, просто проигнорируй письмо. Твой пароль останется прежним.": "If you did not request a password reset, simply ignore this email. Your password will remain unchanged.", + "Игрок": "Player", + "Модератор": "Moderator", + "Гейммастер": "Game Master", + "Администратор": "Administrator", + "Неизвестно": "Unknown", + "Человек": "Human", + "Орк": "Orc", + "Дворф": "Dwarf", + "Ночной эльф": "Night Elf", + "Нежить": "Undead", + "Таурен": "Tauren", + "Гном": "Gnome", + "Тролль": "Troll", + "Воин": "Warrior", + "Паладин": "Paladin", + "Охотник": "Hunter", + "Разбойник": "Rogue", + "Жрец": "Priest", + "Шаман": "Shaman", + "Маг": "Mage", + "Чернокнижник": "Warlock", + "Друид": "Druid", + "Мужской": "Male", + "Женский": "Female", + "%dз %dс %dм": "%dg %ds %dc", + "%dд %dч": "%dd %dh", + "%dч %dм": "%dh %dm", + "%dм": "%dm", + "Пополнение Слёз Элуны": "Tears of Elune deposit", + "Некорректные параметры уведомления Robokassa.": "Invalid Robokassa notification parameters.", + "Некорректная подпись Robokassa.": "Invalid Robokassa signature.", + "Сумма или получатель платежа не совпадают со счётом.": "The payment amount or recipient does not match the invoice.", + "Некорректная сумма.": "Invalid amount.", + "Некорректный курс Слёз Элуны.": "Invalid Tears of Elune exchange rate.", + "Неизвестный алгоритм подписи Robokassa.": "Unknown Robokassa signature algorithm.", + "ROBOKASSA_MODE должен быть test или production.": "ROBOKASSA_MODE must be test or production.", + "Некорректный режим Robokassa.": "Invalid Robokassa mode.", + "Начни своё приключение": "Begin your adventure", + "Создай игровой аккаунт и установи клиент World of Warcraft": "Create your game account and install the World of Warcraft client", + "через лаунчер MoonWell.": "using the MoonWell launcher.", + "Войти в личный кабинет": "Sign in to your account", + "Новости сервера": "Server news", + "Переход к оплате": "Redirecting to payment", + "Переходим к пополнению…": "Redirecting to your deposit…", + "Перейти к оплате": "Continue to payment", + "Привет,": "Hi,", + "Если кнопка не работает, скопируй ссылку в браузер:": "If the button does not work, copy this link into your browser:", + "Это автоматическое письмо, отвечать на него не нужно.": "This is an automated email. Please do not reply.", + "Поддержка:": "Support:", + ":name — PvP сервер WoW Vanilla :version :rate": ":name — WoW Vanilla :version PvP server :rate", + "Новости сервера WoW :version — :name": "WoW :version server news — :name", + "Публичная оферта — :name": "Public Offer — :name", + "Политика обработки персональных данных — :name": "Personal Data Processing Policy — :name", + "Правила проекта — :name": "Project Rules — :name", + "Вход в личный кабинет — :name": "Sign in to your account — :name", + "Восстановление пароля — :name": "Password recovery — :name", + "Подтверждение email — :name": "Email verification — :name", + "Личный кабинет — :name": "My account — :name", + "Аккаунты и инвайты — :name": "Accounts and invites — :name", + "Слёзы Элуны — :name": "Tears of Elune — :name", + "Магазин — :name": "Shop — :name", + "Новости — :name": "News — :name", + "Новости WoW — :name": "WoW News — :name", + ":name — русскоязычный PvP сервер World of Warcraft Vanilla :version с рейтами :rate. Классический Азерот, подземелья и рейды. Создай аккаунт и начни играть.": ":name is a Russian-speaking World of Warcraft Vanilla :version PvP server with :rate rates. Classic Azeroth, dungeons and raids. Create an account and start playing.", + "Новости PvP сервера WoW Vanilla :version :name: обновления, события, патчноуты и объявления проекта.": "News from the :name WoW Vanilla :version PvP server: updates, events, patch notes and project announcements.", + "Публичная оферта о заключении договора возмездного оказания цифровых услуг :name.": "Public Offer for the provision of paid digital services by :name.", + "Политика обработки и защиты данных пользователей :name.": ":name user data processing and protection policy.", + "Запрещённые действия, меры ответственности и порядок обжалования санкций в проекте :name.": "Prohibited actions, penalties and appeals in the :name project.", + "Вход в личный кабинет игрового сервера :name.": "Sign in to your :name game server account.", + "Персонажи, баланс и настройки игрового аккаунта :name.": "Characters, balance and account settings for :name.", + " — страница :page": " — page :page", + "сервер World of Warcraft": "World of Warcraft server", + "Сумма списания должна быть больше нуля.": "The debit amount must be greater than zero.", + "Ключ операции уже использован с другими параметрами.": "This transaction key has already been used with different parameters.", + "Недостаточно средств.": "Insufficient funds.", + "Некорректная корректировка баланса.": "Invalid balance adjustment.", + "Недостаточно средств для ручного списания.": "Insufficient funds for a manual debit." +} diff --git a/lang/ru.json b/lang/ru.json new file mode 100644 index 0000000..a194de1 --- /dev/null +++ b/lang/ru.json @@ -0,0 +1,645 @@ +{ + "Дата вступления в силу": "Дата вступления в силу", + "Содержание": "Содержание", + "Юридическая информация": "Юридическая информация", + "Новости": "Новости", + "Публичная оферта": "Публичная оферта", + "Политика обработки персональных данных": "Политика обработки персональных данных", + "Правила проекта": "Правила проекта", + "© 2026 MoonWell · фанатский проект": "© 2026 MoonWell · фанатский проект", + "MoonWell — главная": "MoonWell — главная", + "Основная навигация": "Основная навигация", + "Главная": "Главная", + "Кабинет": "Кабинет", + "Войти": "Войти", + "Админка": "Админка", + "Оферта": "Оферта", + "Персональные данные": "Персональные данные", + "Правила": "Правила", + "Выйти": "Выйти", + "Закрыть меню": "Закрыть меню", + "Открыть меню": "Открыть меню", + "Мобильная навигация": "Мобильная навигация", + "Личный кабинет": "Личный кабинет", + "Войти в кабинет": "Войти в кабинет", + "Выйти из аккаунта": "Выйти из аккаунта", + "Аккаунты": "Аккаунты", + "Слёзы Элуны": "Слёзы Элуны", + "Магазин": "Магазин", + "Новости WoW": "Новости WoW", + "Разделы админки": "Разделы админки", + "Всего кодов": "Всего кодов", + "Доступно": "Доступно", + "Использовано": "Использовано", + "Управление доступом": "Управление доступом", + "Аккаунты и инвайты": "Аккаунты и инвайты", + "Игроки, боты, персонажи и уровни доступа — в одном рабочем пространстве.": "Игроки, боты, персонажи и уровни доступа — в одном рабочем пространстве.", + "Всего аккаунтов": "Всего аккаунтов", + "Инвайты": "Инвайты", + "Доступ к регистрации": "Доступ к регистрации", + "Регистрация требует действующий инвайт-код.": "Регистрация требует действующий инвайт-код.", + "Регистрация сейчас открыта без инвайт-кодов.": "Регистрация сейчас открыта без инвайт-кодов.", + "Новая партия": "Новая партия", + "Создать коды": "Создать коды", + "Количество": "Количество", + "Создаём…": "Создаём…", + "Последние коды": "Последние коды", + "Использован": "Использован", + "Свободен": "Свободен", + "Инвайт-коды ещё не создавались.": "Инвайт-коды ещё не создавались.", + "Игроки и персонажи": "Игроки и персонажи", + "Поиск по логину или email, управление GM-уровнем и просмотр состава аккаунта.": "Поиск по логину или email, управление GM-уровнем и просмотр состава аккаунта.", + "Логин или email": "Логин или email", + "Найти": "Найти", + "Сбросить": "Сбросить", + "Доступ": "Доступ", + "Регистрация": "Регистрация", + "Последний вход": "Последний вход", + "Персонажи": "Персонажи", + "Уровень доступа": "Уровень доступа", + "Сохраняем…": "Сохраняем…", + "Сохранить": "Сохранить", + "Скрыть персонажей": "Скрыть персонажей", + "Персонажи · {0}": "Персонажи · {0}", + "ур.": "ур.", + "Онлайн": "Онлайн", + "Оффлайн": "Оффлайн", + "На аккаунте пока нет персонажей.": "На аккаунте пока нет персонажей.", + "Боты не найдены": "Боты не найдены", + "Игроки не найдены": "Игроки не найдены", + "Измени поисковый запрос или переключись на другую вкладку.": "Измени поисковый запрос или переключись на другую вкладку.", + "Финансы": "Финансы", + "Ручные корректировки и полный журнал движения виртуальной валюты.": "Ручные корректировки и полный журнал движения виртуальной валюты.", + "Операций в выборке": "Операций в выборке", + "Новая операция": "Новая операция", + "Изменить баланс": "Изменить баланс", + "ID или логин аккаунта": "ID или логин аккаунта", + "Операция": "Операция", + "Начислить": "Начислить", + "Снять": "Снять", + "Причина": "Причина", + "Компенсация, возврат, нарушение…": "Компенсация, возврат, нарушение…", + "Проводим…": "Проводим…", + "Провести операцию": "Провести операцию", + "Контроль": "Контроль", + "Правила операций": "Правила операций", + "Баланс аккаунта не может стать отрицательным.": "Баланс аккаунта не может стать отрицательным.", + "В журнале сохраняются администратор, причина и итоговое значение.": "В журнале сохраняются администратор, причина и итоговое значение.", + "Ошибочная операция исправляется отдельной обратной корректировкой.": "Ошибочная операция исправляется отдельной обратной корректировкой.", + "Аудит": "Аудит", + "Последние транзакции": "Последние транзакции", + "Логин или ID аккаунта": "Логин или ID аккаунта", + "Администратор:": "Администратор:", + "Баланс:": "Баланс:", + "Транзакции не найдены.": "Транзакции не найдены.", + "Название": "Название", + "Иконка клиента": "Иконка клиента", + "Требуемый ранг": "Требуемый ранг", + "Флаги": "Флаги", + "Категория включена": "Категория включена", + "Порядок вывода": "Порядок вывода", + "Заменить картинку": "Заменить картинку", + "Картинка": "Картинка", + "Оставь пустым, чтобы сохранить текущую. Максимум 10 МБ.": "Оставь пустым, чтобы сохранить текущую. Максимум 10 МБ.", + "Максимум 10 МБ. Необязательно.": "Максимум 10 МБ. Необязательно.", + "Категории": "Категории", + "Можно выбрать несколько с Ctrl/Cmd.": "Можно выбрать несколько с Ctrl/Cmd.", + "Тип": "Тип", + "Название подсказки": "Название подсказки", + "Тип подсказки": "Тип подсказки", + "item или spell": "item или spell", + "Текст подсказки": "Текст подсказки", + "Цена, Слёзы Элуны": "Цена, Слёзы Элуны", + "Скидка, %": "Скидка, %", + "Награды · 8 слотов": "Награды · 8 слотов", + "Награда": "Награда", + "Пометка «Новинка»": "Пометка «Новинка»", + "Позиция включена": "Позиция включена", + "Всего": "Всего", + "Опубликовано": "Опубликовано", + "Черновики": "Черновики", + "Поддерживаются WoW-коды цветов |cFF...|r, иконки |T...|t, переносы строк и обычный текст.": "Поддерживаются WoW-коды цветов |cFF...|r, иконки |T...|t, переносы строк и обычный текст.", + "Название в админке": "Название в админке", + "Сразу публиковать эту запись": "Сразу публиковать эту запись", + "Текст для клиента": "Текст для клиента", + "Добавить запись": "Добавить запись", + "Удалить запись": "Удалить запись", + "Удалить эту запись из фида?": "Удалить эту запись из фида?", + "Показывать в публичном фиде": "Показывать в публичном фиде", + "Текст блока": "Текст блока", + "Сохранить запись": "Сохранить запись", + "|TInterface\\ICONS\\INV_Misc_QuestionMark:25:25:0:-5|t |cFF00D1FFКлассический Азерот|r |cFFA6D8FF- Клиент 1.12.1 (build 5875)|r |cFFA6D8FF- Собери отряд и отправляйся в приключение|r": "|TInterface\\ICONS\\INV_Misc_QuestionMark:25:25:0:-5|t |cFF00D1FFКлассический Азерот|r |cFFA6D8FF- Клиент 1.12.1 (build 5875)|r |cFFA6D8FF- Собери отряд и отправляйся в приключение|r", + "Клиент 1.12.1": "Клиент 1.12.1", + "Новости логин-экрана WoW": "Новости логин-экрана WoW", + "Raw-текст с игровой разметкой и готовый публичный SERVERALERT-фид.": "Raw-текст с игровой разметкой и готовый публичный SERVERALERT-фид.", + "Публичная ссылка": "Публичная ссылка", + "Фид для клиента": "Фид для клиента", + "Ссылка доступна без авторизации и всегда возвращает plain text.": "Ссылка доступна без авторизации и всегда возвращает plain text.", + "Открыть фид": "Открыть фид", + "Итоговый ответ": "Итоговый ответ", + "Превью сервера": "Превью сервера", + "Новая запись": "Новая запись", + "Добавить новость": "Добавить новость", + "Шаблон": "Шаблон", + "Пример WoW-блока": "Пример WoW-блока", + "Префикс SERVERALERT добавляется автоматически.": "Префикс SERVERALERT добавляется автоматически.", + "Текущие записи": "Текущие записи", + "Редактирование и публикация": "Редактирование и публикация", + "Опубликованные записи попадают в фид по порядку sort_order.": "Опубликованные записи попадают в фид по порядку sort_order.", + "Порядок:": "Порядок:", + "Черновик": "Черновик", + "Записей пока нет.": "Записей пока нет.", + "Заголовок": "Заголовок", + "Сразу опубликовать": "Сразу опубликовать", + "Текст новости": "Текст новости", + "Удалить": "Удалить", + "Удалить эту новость?": "Удалить эту новость?", + "Сохранить новость": "Сохранить новость", + "Контент": "Контент", + "Публикации для лендинга и лаунчера с изображениями из S3-хранилища.": "Публикации для лендинга и лаунчера с изображениями из S3-хранилища.", + "Редактирование": "Редактирование", + "Записи отсортированы по полю порядка вывода.": "Записи отсортированы по полю порядка вывода.", + "Новостей пока нет": "Новостей пока нет", + "Добавь первую публикацию.": "Добавь первую публикацию.", + "Позиции": "Позиции", + "Активные": "Активные", + "Игровой сервер": "Игровой сервер", + "Категории, товары, награды и цены из базы игрового модуля.": "Категории, товары, награды и цены из базы игрового модуля.", + "Позиций": "Позиций", + "Глобальная доступность": "Глобальная доступность", + "Магазин и пополнение": "Магазин и пополнение", + "При выключении игровой магазин становится недоступен, а форма пополнения скрывается в личном кабинете.": "При выключении игровой магазин становится недоступен, а форма пополнения скрывается в личном кабинете.", + "Доступны": "Доступны", + "Выключены": "Выключены", + "Изменения записываются прямо в базу": "Изменения записываются прямо в базу", + ". После сохранения перезагрузи данные магазина командой модуля или перезапусти worldserver.": ". После сохранения перезагрузи данные магазина командой модуля или перезапусти worldserver.", + "Навигация магазина": "Навигация магазина", + "Новая категория": "Новая категория", + "Создать категорию": "Создать категорию", + "Контент магазина": "Контент магазина", + "Новая позиция": "Новая позиция", + "Создать позицию": "Создать позицию", + "Сначала создай категорию.": "Сначала создай категорию.", + "Включена": "Включена", + "Отключена": "Отключена", + "позиций": "позиций", + "Сохранить категорию": "Сохранить категорию", + "Удалить категорию и её связи с товарами?": "Удалить категорию и её связи с товарами?", + "Удалить категорию": "Удалить категорию", + "Категорий пока нет.": "Категорий пока нет.", + "Товары и услуги": "Товары и услуги", + "Без категории": "Без категории", + "· тип": "· тип", + "Слёз Элуны": "Слёз Элуны", + "Сохранить позицию": "Сохранить позицию", + "Удалить позицию магазина?": "Удалить позицию магазина?", + "Удалить позицию": "Удалить позицию", + "Позиций пока нет.": "Позиций пока нет.", + "Восстановление доступа": "Восстановление доступа", + "Твоё приключение продолжается": "Твоё приключение продолжается", + "Забытый пароль — лишь короткая остановка на пути. Укажи почту аккаунта, и мы поможем вернуться в Азерот.": "Забытый пароль — лишь короткая остановка на пути. Укажи почту аккаунта, и мы поможем вернуться в Азерот.", + "Забыл пароль?": "Забыл пароль?", + "Пришлём ссылку для создания нового пароля на email, указанный при регистрации.": "Пришлём ссылку для создания нового пароля на email, указанный при регистрации.", + "Отправить ссылку": "Отправить ссылку", + "Укажи почту своего аккаунта": "Укажи почту своего аккаунта", + "Открой письмо от MoonWell": "Открой письмо от MoonWell", + "Задай новый пароль и возвращайся": "Задай новый пароль и возвращайся", + "Безопасность аккаунта": "Безопасность аккаунта", + "Новый ключ к твоему миру": "Новый ключ к твоему миру", + "Выбери новый пароль для сайта и лаунчера. Твои герои и прогресс будут ждать тебя на прежнем месте.": "Выбери новый пароль для сайта и лаунчера. Твои герои и прогресс будут ждать тебя на прежнем месте.", + "Новый пароль": "Новый пароль", + "От 8 до 16 символов, хотя бы одна латинская буква и одна цифра.": "От 8 до 16 символов, хотя бы одна латинская буква и одна цифра.", + "Сохранить пароль": "Сохранить пароль", + "Придумай новый пароль": "Придумай новый пароль", + "Повтори его для подтверждения": "Повтори его для подтверждения", + "Войди на сайт или в лаунчер": "Войди на сайт или в лаунчер", + "Добро пожаловать в MoonWell": "Добро пожаловать в MoonWell", + "Один шаг до приключения": "Один шаг до приключения", + "Подтверди свою почту, чтобы открыть вход в личный кабинет и лаунчер. Она также поможет восстановить доступ к аккаунту.": "Подтверди свою почту, чтобы открыть вход в личный кабинет и лаунчер. Она также поможет восстановить доступ к аккаунту.", + "Проверь свою почту": "Проверь свою почту", + "Открой письмо от no-reply@moon-well.online и нажми «Подтвердить email». Ссылка действует 60 минут.": "Открой письмо от no-reply@moon-well.online и нажми «Подтвердить email». Ссылка действует 60 минут.", + "Отправить письмо повторно": "Отправить письмо повторно", + "Подтверди email по ссылке": "Подтверди email по ссылке", + "Войди в аккаунт и начни играть": "Войди в аккаунт и начни играть", + "Всё готово": "Всё готово", + "Азерот ждёт тебя": "Азерот ждёт тебя", + "Почта подтверждена. Теперь можно войти в личный кабинет и лаунчер с логином и паролем, которые ты указал при регистрации.": "Почта подтверждена. Теперь можно войти в личный кабинет и лаунчер с логином и паролем, которые ты указал при регистрации.", + "Email подтверждён": "Email подтверждён", + "Добро пожаловать в MoonWell. Пусть лунный свет освещает твой путь.": "Добро пожаловать в MoonWell. Пусть лунный свет освещает твой путь.", + "Почта подтверждена": "Почта подтверждена", + "Аккаунт готов к входу": "Аккаунт готов к входу", + "Приключение начинается": "Приключение начинается", + "Нужна новая ссылка": "Нужна новая ссылка", + "Продолжим твой путь": "Продолжим твой путь", + "Срок действия ссылки истёк или она была изменена. Запроси новое письмо, чтобы подтвердить почту.": "Срок действия ссылки истёк или она была изменена. Запроси новое письмо, чтобы подтвердить почту.", + "Ссылка недействительна": "Ссылка недействительна", + "Вернись на страницу подтверждения и укажи email своего аккаунта. Мы пришлём новую ссылку.": "Вернись на страницу подтверждения и укажи email своего аккаунта. Мы пришлём новую ссылку.", + "Запроси новое письмо": "Запроси новое письмо", + "Используй ссылку из письма": "Используй ссылку из письма", + "Возвращайся в MoonWell": "Возвращайся в MoonWell", + "Твой аккаунт": "Твой аккаунт", + "Email аккаунта": "Email аккаунта", + "8–16 символов": "8–16 символов", + "Повтори пароль": "Повтори пароль", + "Ещё раз новый пароль": "Ещё раз новый пароль", + "Отправляем…": "Отправляем…", + "Повторить через {0} с": "Повторить через {0} с", + "Не видишь письмо? Проверь папку «Спам» и правильность адреса. Повторная отправка доступна раз в минуту.": "Не видишь письмо? Проверь папку «Спам» и правильность адреса. Повторная отправка доступна раз в минуту.", + "Запросить новую ссылку": "Запросить новую ссылку", + "В личный кабинет": "В личный кабинет", + "Войти в аккаунт": "Войти в аккаунт", + "Запросить новое письмо": "Запросить новое письмо", + "← Вернуться ко входу": "← Вернуться ко входу", + "На главную MoonWell": "На главную MoonWell", + "Вернись к своему приключению": "Вернись к своему приключению", + "Используй логин и пароль игрового аккаунта. Здесь тебя ждут персонажи, баланс, настройки безопасности и лаунчер.": "Используй логин и пароль игрового аккаунта. Здесь тебя ждут персонажи, баланс, настройки безопасности и лаунчер.", + "Все персонажи аккаунта в одной сводке": "Все персонажи аккаунта в одной сводке", + "Пополнение Слёз Элуны и история операций": "Пополнение Слёз Элуны и история операций", + "Смена игрового пароля без ручных команд": "Смена игрового пароля без ручных команд", + "Авторизация": "Авторизация", + "Вход выполняется по игровым учётным данным.": "Вход выполняется по игровым учётным данным.", + "Логин": "Логин", + "Пароль": "Пароль", + "Твой пароль": "Твой пароль", + "Входим…": "Входим…", + "Не пришло письмо подтверждения?": "Не пришло письмо подтверждения?", + "← Вернуться на главную": "← Вернуться на главную", + "Обзор": "Обзор", + "Безопасность": "Безопасность", + "С возвращением,": "С возвращением,", + "Твой аккаунт, герои и всё необходимое для следующего путешествия.": "Твой аккаунт, герои и всё необходимое для следующего путешествия.", + "Статус аккаунта": "Статус аккаунта", + "Навигация": "Навигация", + "Баланс": "Баланс", + "Данные аккаунта": "Данные аккаунта", + "Открыть админку": "Открыть админку", + "Персонажей": "Персонажей", + "Лаунчер": "Лаунчер", + "Врата в Азерот": "Врата в Азерот", + "Лаунчер автоматически скачает клиент, настроит realmlist и установит обновления.": "Лаунчер автоматически скачает клиент, настроит realmlist и установит обновления.", + "Скачать лаунчер": "Скачать лаунчер", + "Мои персонажи": "Мои персонажи", + "Герои аккаунта": "Герои аккаунта", + "герой": "герой", + "героев": "героев", + "Пол": "Пол", + "Золото": "Золото", + "Время в игре": "Время в игре", + "Персонажей пока нет": "Персонажей пока нет", + "Создай первого героя в игре — он появится здесь автоматически.": "Создай первого героя в игре — он появится здесь автоматически.", + "Баланс и операции": "Баланс и операции", + "Доступно сейчас": "Доступно сейчас", + "Сумма пополнения, ₽": "Сумма пополнения, ₽", + "Пополнить": "Пополнить", + "От": "От", + "до": "до", + "Последние операции": "Последние операции", + "Операций пока нет.": "Операций пока нет.", + "Сменить пароль": "Сменить пароль", + "Защити свой аккаунт": "Защити свой аккаунт", + "После обновления новый пароль сразу начнёт действовать и в игре, и в личном кабинете.": "После обновления новый пароль сразу начнёт действовать и в игре, и в личном кабинете.", + "От 8 до 16 символов": "От 8 до 16 символов", + "Хотя бы одна буква и одна цифра": "Хотя бы одна буква и одна цифра", + "Не используй пароль от почты": "Не используй пароль от почты", + "Текущий пароль": "Текущий пароль", + "Повтори новый пароль": "Повтори новый пароль", + "Обновляем…": "Обновляем…", + "Обновить пароль": "Обновить пароль", + "Что такое MoonWell": "Что такое MoonWell", + "Эпоха за эпохой": "Эпоха за эпохой", + "MoonWell — русскоязычный PvP сервер World of Warcraft Vanilla": "MoonWell — русскоязычный PvP сервер World of Warcraft Vanilla", + ". Развивай героя до 60-го уровня и исследуй классический Азерот: оригинальные зоны, подземелья и рейды.": ". Развивай героя до 60-го уровня и исследуй классический Азерот: оригинальные зоны, подземелья и рейды.", + "Исследуй опасные земли, готовься к первым рейдам и возвращайся к приключению тогда, когда тебе удобно. Здесь не нужно подстраиваться под общий прогресс сервера.": "Исследуй опасные земли, готовься к первым рейдам и возвращайся к приключению тогда, когда тебе удобно. Здесь не нужно подстраиваться под общий прогресс сервера.", + "«Исследуй классический Азерот в одиночку или собери небольшой отряд для приключений».": "«Исследуй классический Азерот в одиночку или собери небольшой отряд для приключений».", + "— команда MoonWell": "— команда MoonWell", + "Концепт-арт: вход в Лунный колодец, туман и силуэт ночного эльфа": "Концепт-арт: вход в Лунный колодец, туман и силуэт ночного эльфа", + "Особенности сервера": "Особенности сервера", + "Системы MoonWell": "Системы MoonWell", + "Классический Азерот и планы развития MoonWell.": "Классический Азерот и планы развития MoonWell.", + "Уровень за уровнем": "Уровень за уровнем", + "Твой герой · Aranthel, Хранитель рощи · 47 ур.": "Твой герой · Aranthel, Хранитель рощи · 47 ур.", + "Простая установка": "Простая установка", + "Твоя история начинается здесь": "Твоя история начинается здесь", + "Лаунчер сам скачает, установит и обновит игровой клиент. Тебе не придётся вручную ставить патчи или менять файлы.": "Лаунчер сам скачает, установит и обновит игровой клиент. Тебе не придётся вручную ставить патчи или менять файлы.", + "Скачай лаунчер": "Скачай лаунчер", + "Войди или создай аккаунт": "Войди или создай аккаунт", + "Нажми «Играть» — остальное сделает лаунчер": "Нажми «Играть» — остальное сделает лаунчер", + "Скачай лаунчер для Windows": "Скачай лаунчер для Windows", + "macOS — в разработке": "macOS — в разработке", + "v 1.4.2 · автоматическая установка и обновления": "v 1.4.2 · автоматическая установка и обновления", + "Сервер World of Warcraft 1.12.1. Исследуй классический Азерот, развивай героя до 60-го уровня и играй вместе с друзьями.": "Сервер World of Warcraft 1.12.1. Исследуй классический Азерот, развивай героя до 60-го уровня и играй вместе с друзьями.", + "Сервер": "Сервер", + "Статус реалмов": "Статус реалмов", + "Скачать клиент": "Скачать клиент", + "Сообщество": "Сообщество", + "ВКонтакте": "ВКонтакте", + "Поддержка": "Поддержка", + "© 2026 MoonWell · фанатский проект · не связан с Blizzard Entertainment": "© 2026 MoonWell · фанатский проект · не связан с Blizzard Entertainment", + "Сделано с лунным светом": "Сделано с лунным светом", + "MoonWell · PvP сервер WoW Vanilla": "MoonWell · PvP сервер WoW Vanilla", + "История Азерота в твоём темпе": "История Азерота в твоём темпе", + "— Лунный колодец —": "— Лунный колодец —", + "Вернись в классический Азерот — в одиночку или с небольшим отрядом.": "Вернись в классический Азерот — в одиночку или с небольшим отрядом.", + "Личный прогресс": "Личный прогресс", + "Одиночные подземелья": "Одиночные подземелья", + "Режим «Предатель»": "Режим «Предатель»", + "Мифик+ без предела": "Мифик+ без предела", + "Начни играть": "Начни играть", + "Узнай больше": "Узнай больше", + "Автоматическая установка и обновления": "Автоматическая установка и обновления", + "Прокрути вниз": "Прокрути вниз", + "Используй латиницу и цифры. Регистр символов при входе не имеет значения.": "Используй латиницу и цифры. Регистр символов при входе не имеет значения.", + "Регистрация доступна только по действующему инвайт-коду.": "Регистрация доступна только по действующему инвайт-коду.", + "Только латиница и цифры, 3–16 символов.": "Только латиница и цифры, 3–16 символов.", + "Похоже на некорректный email.": "Похоже на некорректный email.", + "Код из букв, цифр и тире, 6-32 символа.": "Код из букв, цифр и тире, 6-32 символа.", + "Минимум 8 символов.": "Минимум 8 символов.", + "Не более 16 символов.": "Не более 16 символов.", + "Должна быть хотя бы одна буква.": "Должна быть хотя бы одна буква.", + "Должна быть хотя бы одна цифра.": "Должна быть хотя бы одна цифра.", + "Пароли не совпадают.": "Пароли не совпадают.", + "Добро пожаловать": "Добро пожаловать", + "Аккаунт создан": "Аккаунт создан", + "Подтверди email по ссылке в письме от MoonWell, чтобы войти в аккаунт и лаунчер.": "Подтверди email по ссылке в письме от MoonWell, чтобы войти в аккаунт и лаунчер.", + "Войди в личный кабинет": "Войди в личный кабинет", + "Создай игровой аккаунт": "Создай игровой аккаунт", + "Укажи инвайт-код, логин, почту и пароль": "Укажи инвайт-код, логин, почту и пароль", + "Укажи логин, почту и пароль": "Укажи логин, почту и пароль", + ". Затем подтверди email по ссылке из письма — и начинай приключение.": ". Затем подтверди email по ссылке из письма — и начинай приключение.", + "Закрытый бета-доступ": "Закрытый бета-доступ", + "Пока идёт закрытый этап: регистрация только по инвайт-кодам. Код можно получить у друзей-игроков или в нашем": "Пока идёт закрытый этап: регистрация только по инвайт-кодам. Код можно получить у друзей-игроков или в нашем", + "Telegram-сообществе": "Telegram-сообществе", + "Инвайт-код": "Инвайт-код", + "Повтор пароля": "Повтор пароля", + "Я принимаю условия документов:": "Я принимаю условия документов:", + "Я принимаю правила проекта и согласен на обработку данных для создания игрового аккаунта.": "Я принимаю правила проекта и согласен на обработку данных для создания игрового аккаунта.", + "Создаём аккаунт…": "Создаём аккаунт…", + "Создай аккаунт": "Создай аккаунт", + "Войди в кабинет": "Войди в кабинет", + "Играй": "Играй", + "Хроники сервера": "Хроники сервера", + "Новости и патчноуты": "Новости и патчноуты", + "Все новости": "Все новости", + "Прочитать новость «{0}»": "Прочитать новость «{0}»", + "Читать полностью": "Читать полностью", + "Пока новостей нет": "Пока новостей нет", + "Хроники сервера ещё не открыли первую страницу. Загляни позже — здесь появятся заметки об обновлениях, события и дневники разработки.": "Хроники сервера ещё не открыли первую страницу. Загляни позже — здесь появятся заметки об обновлениях, события и дневники разработки.", + "Игроков": "Игроков", + "Аптайм": "Аптайм", + "Пинг": "Пинг", + "Фракции": "Фракции", + "О сервере": "О сервере", + "Особенности": "Особенности", + "Главная особенность": "Главная особенность", + "Исследуй классические зоны Азерота и развивай героя до 60-го уровня в своём темпе.": "Исследуй классические зоны Азерота и развивай героя до 60-го уровня в своём темпе.", + "Классические подземелья": "Классические подземелья", + "Собери отряд и отправляйся в подземелья оригинального World of Warcraft.": "Собери отряд и отправляйся в подземелья оригинального World of Warcraft.", + "В разработке": "В разработке", + "Создай персонажа Альянса на стороне Орды или персонажа Орды на стороне Альянса. Тебя ждут отдельные сюжеты, персонажи и репутации.": "Создай персонажа Альянса на стороне Орды или персонажа Орды на стороне Альянса. Тебя ждут отдельные сюжеты, персонажи и репутации.", + "Ликантропия": "Ликантропия", + "Заразись ликантропией, сопротивляйся ей или прими новую форму. Выбор определит твой путь.": "Заразись ликантропией, сопротивляйся ей или прими новую форму. Выбор определит твой путь.", + "Рейды Азерота": "Рейды Азерота", + "Собери союзников и пройди рейды классического World of Warcraft.": "Собери союзников и пройди рейды классического World of Warcraft.", + "Боевой пропуск · магазин наград": "Боевой пропуск · магазин наград", + "Выполняй ежедневные и еженедельные задания, получай награды за вход и открывай косметические предметы.": "Выполняй ежедневные и еженедельные задания, получай награды за вход и открывай косметические предметы.", + "Коллекции": "Коллекции", + "Собирай питомцев, ездовых животных и облики для трансмогрификации в одном журнале с фильтрами и отметками прогресса.": "Собирай питомцев, ездовых животных и облики для трансмогрификации в одном журнале с фильтрами и отметками прогресса.", + "Отдельный проект": "Отдельный проект", + "Участвуй в аркадных гонках с персонажами сервера. Мини-игра находится в разработке.": "Участвуй в аркадных гонках с персонажами сервера. Мини-игра находится в разработке.", + "Первые шаги": "Первые шаги", + "Классика": "Классика", + "Путь героя": "Путь героя", + "Высокие уровни": "Высокие уровни", + "Защита данных": "Защита данных", + "Редакция от": "Редакция от", + "Правила MoonWell": "Правила MoonWell", + "Назад": "Назад", + "Вперёд": "Вперёд", + "Новости и обновления": "Новости и обновления", + "События MoonWell, патчноуты, дневники разработки и важные объявления проекта.": "События MoonWell, патчноуты, дневники разработки и важные объявления проекта.", + "Хроники пока пусты": "Хроники пока пусты", + "Первая новость появится здесь после публикации.": "Первая новость появится здесь после публикации.", + "Вернуться на главную": "Вернуться на главную", + "Страницы новостей": "Страницы новостей", + "Вернуться к хроникам сервера": "Вернуться к хроникам сервера", + "Продолжить чтение": "Продолжить чтение", + "Другие новости": "Другие новости", + "Читать": "Читать", + "Язык сайта": "Язык сайта", + "Авто": "Авто", + "Укажи уровень доступа.": "Укажи уровень доступа.", + "Уровень доступа должен быть числом.": "Уровень доступа должен быть числом.", + "Выбран недопустимый уровень доступа.": "Выбран недопустимый уровень доступа.", + "Укажи количество инвайт-кодов.": "Укажи количество инвайт-кодов.", + "Количество должно быть числом.": "Количество должно быть числом.", + "Нужно создать минимум один инвайт-код.": "Нужно создать минимум один инвайт-код.", + "За один раз можно создать не больше 50 инвайт-кодов.": "За один раз можно создать не больше 50 инвайт-кодов.", + "Ссылка недействительна. Запроси новое письмо.": "Ссылка недействительна. Запроси новое письмо.", + "Укажи логин аккаунта.": "Укажи логин аккаунта.", + "Логин должен содержать минимум 3 символа.": "Логин должен содержать минимум 3 символа.", + "Логин не должен быть длиннее 16 символов.": "Логин не должен быть длиннее 16 символов.", + "Логин может содержать только латинские буквы и цифры.": "Логин может содержать только латинские буквы и цифры.", + "Укажи пароль.": "Укажи пароль.", + "Пароль должен содержать минимум 3 символа.": "Пароль должен содержать минимум 3 символа.", + "Пароль не должен быть длиннее 255 символов.": "Пароль не должен быть длиннее 255 символов.", + "Логин должен быть строкой.": "Логин должен быть строкой.", + "Укажи email для аккаунта.": "Укажи email для аккаунта.", + "Email должен быть строкой.": "Email должен быть строкой.", + "Укажи корректный email адрес.": "Укажи корректный email адрес.", + "Email не должен быть длиннее 255 символов.": "Email не должен быть длиннее 255 символов.", + "Укажи инвайт-код.": "Укажи инвайт-код.", + "Инвайт-код должен быть строкой.": "Инвайт-код должен быть строкой.", + "Инвайт-код выглядит слишком коротким.": "Инвайт-код выглядит слишком коротким.", + "Инвайт-код выглядит слишком длинным.": "Инвайт-код выглядит слишком длинным.", + "Пароль должен быть строкой.": "Пароль должен быть строкой.", + "Пароль должен содержать минимум 8 символов.": "Пароль должен содержать минимум 8 символов.", + "Пароль не должен быть длиннее 16 символов.": "Пароль не должен быть длиннее 16 символов.", + "Подтверждение пароля не совпадает.": "Подтверждение пароля не совпадает.", + "Пароль должен содержать хотя бы одну букву и одну цифру.": "Пароль должен содержать хотя бы одну букву и одну цифру.", + "Нужно принять правила сервера и пользовательское соглашение.": "Нужно принять правила сервера и пользовательское соглашение.", + "логин аккаунта": "логин аккаунта", + "инвайт-код": "инвайт-код", + "пароль": "пароль", + "правила": "правила", + "Укажи название новости для админки.": "Укажи название новости для админки.", + "Название новости не должно быть длиннее 120 символов.": "Название новости не должно быть длиннее 120 символов.", + "Заполни текст новости для логин-экрана.": "Заполни текст новости для логин-экрана.", + "Текст новости слишком длинный.": "Текст новости слишком длинный.", + "Укажи порядок вывода.": "Укажи порядок вывода.", + "Порядок вывода должен быть числом.": "Порядок вывода должен быть числом.", + "Порядок вывода не может быть отрицательным.": "Порядок вывода не может быть отрицательным.", + "Порядок вывода слишком большой.": "Порядок вывода слишком большой.", + "Укажи заголовок новости.": "Укажи заголовок новости.", + "Заголовок не должен быть длиннее 120 символов.": "Заголовок не должен быть длиннее 120 символов.", + "Заполни текст новости.": "Заполни текст новости.", + "Файл должен быть изображением.": "Файл должен быть изображением.", + "Изображение не должно превышать 10 МБ.": "Изображение не должно превышать 10 МБ.", + "Укажи email аккаунта.": "Укажи email аккаунта.", + "Укажи корректный email.": "Укажи корректный email.", + "Укажи текущий пароль.": "Укажи текущий пароль.", + "Текущий пароль не должен быть длиннее 255 символов.": "Текущий пароль не должен быть длиннее 255 символов.", + "Укажи новый пароль.": "Укажи новый пароль.", + "Новый пароль должен содержать минимум 8 символов.": "Новый пароль должен содержать минимум 8 символов.", + "Новый пароль не должен быть длиннее 16 символов.": "Новый пароль не должен быть длиннее 16 символов.", + "Подтверждение нового пароля не совпадает.": "Подтверждение нового пароля не совпадает.", + "Новый пароль должен содержать хотя бы одну букву и одну цифру.": "Новый пароль должен содержать хотя бы одну букву и одну цифру.", + "Пополнение временно недоступно.": "Пополнение временно недоступно.", + "Пополнение принято. Слёзы Элуны скоро появятся на счёте.": "Пополнение принято. Слёзы Элуны скоро появятся на счёте.", + "Оплата отменена или не была завершена.": "Оплата отменена или не была завершена.", + "Заблокирован": "Заблокирован", + "Активен": "Активен", + "Пополнение": "Пополнение", + "Начисление": "Начисление", + "Списание": "Списание", + "Покупка в игре": "Покупка в игре", + "Текущий пароль указан неверно.": "Текущий пароль указан неверно.", + "Пароль успешно обновлён.": "Пароль успешно обновлён.", + "Не удалось получить ссылку на клиент. Попробуй ещё раз позже.": "Не удалось получить ссылку на клиент. Попробуй ещё раз позже.", + "Не удалось получить список реалмов.": "Не удалось получить список реалмов.", + "Манифест не найден.": "Манифест не найден.", + "Недопустимый путь.": "Недопустимый путь.", + "Файл не найден в манифесте.": "Файл не найден в манифесте.", + "Некорректный ключ файла в манифесте.": "Некорректный ключ файла в манифесте.", + "Файл не найден.": "Файл не найден.", + "Неверный логин или пароль.": "Неверный логин или пароль.", + "Подтверди email, чтобы войти в аккаунт.": "Подтверди email, чтобы войти в аккаунт.", + "Не удалось создать аккаунт. Попробуй снова позже.": "Не удалось создать аккаунт. Попробуй снова позже.", + "Игровой аккаунт успешно создан.": "Игровой аккаунт успешно создан.", + "Не удалось создать аккаунт. Проверь настройки сервера и попробуй снова.": "Не удалось создать аккаунт. Проверь настройки сервера и попробуй снова.", + "Аккаунт %s создан. Подтверди email по ссылке в письме. Если письмо не пришло, запроси его повторно ниже.": "Аккаунт %s создан. Подтверди email по ссылке в письме. Если письмо не пришло, запроси его повторно ниже.", + "Предмет": "Предмет", + "Маунт": "Маунт", + "Питомец": "Питомец", + "Бафф": "Бафф", + "Услуга персонажа": "Услуга персонажа", + "Буст": "Буст", + "Титул": "Титул", + "Магазин и пополнение включены.": "Магазин и пополнение включены.", + "Магазин и пополнение выключены.": "Магазин и пополнение выключены.", + "Категория создана.": "Категория создана.", + "Категория обновлена.": "Категория обновлена.", + "Категория удалена.": "Категория удалена.", + "Товар создан.": "Товар создан.", + "Товар обновлён.": "Товар обновлён.", + "Товар удалён.": "Товар удалён.", + "Аккаунт удалён": "Аккаунт удалён", + "Начисление администратором": "Начисление администратором", + "Списание администратором": "Списание администратором", + "Игровой аккаунт не найден.": "Игровой аккаунт не найден.", + "Счёт %s изменён. Теперь на нём %s Слёз Элуны.": "Счёт %s изменён. Теперь на нём %s Слёз Элуны.", + "Создано инвайт-кодов: %d.": "Создано инвайт-кодов: %d.", + "Новость добавлена.": "Новость добавлена.", + "Новость обновлена.": "Новость обновлена.", + "Новость удалена.": "Новость удалена.", + "email не указан": "email не указан", + "ещё не использован": "ещё не использован", + "Игроки": "Игроки", + "Боты": "Боты", + "Уровень доступа обновлён.": "Уровень доступа обновлён.", + "Новость для логин-экрана добавлена.": "Новость для логин-экрана добавлена.", + "Новость для логин-экрана обновлена.": "Новость для логин-экрана обновлена.", + "Новость для логин-экрана удалена.": "Новость для логин-экрана удалена.", + "Не удалось отправить письмо. Попробуй ещё раз через минуту.": "Не удалось отправить письмо. Попробуй ещё раз через минуту.", + "Если аккаунт с такой почтой существует, письмо со ссылкой для восстановления придёт в течение нескольких минут. Проверь также папку «Спам».": "Если аккаунт с такой почтой существует, письмо со ссылкой для восстановления придёт в течение нескольких минут. Проверь также папку «Спам».", + "Ссылка недействительна или срок её действия истёк. Запроси новое письмо.": "Ссылка недействительна или срок её действия истёк. Запроси новое письмо.", + "Пароль обновлён. Войди в аккаунт с новым паролем.": "Пароль обновлён. Войди в аккаунт с новым паролем.", + "Если этот email ожидает подтверждения, мы отправим письмо. Повторный запрос доступен через минуту. Проверь также папку «Спам».": "Если этот email ожидает подтверждения, мы отправим письмо. Повторный запрос доступен через минуту. Проверь также папку «Спам».", + "Сеанс завершён. Можно войти снова.": "Сеанс завершён. Можно войти снова.", + "Недостаточно прав для доступа к админке.": "Недостаточно прав для доступа к админке.", + "Неверный токен игрового сервера.": "Неверный токен игрового сервера.", + "Такой игровой аккаунт уже существует.": "Такой игровой аккаунт уже существует.", + "На этот email уже зарегистрирован аккаунт.": "На этот email уже зарегистрирован аккаунт.", + "Укажи корректный инвайт-код.": "Укажи корректный инвайт-код.", + "Этот инвайт-код уже использован.": "Этот инвайт-код уже использован.", + "Подтверди email — MoonWell": "Подтверди email — MoonWell", + "Ещё один шаг до Азерота": "Ещё один шаг до Азерота", + "Подтверди свой email": "Подтверди свой email", + "Добро пожаловать в MoonWell! Подтверди почту, чтобы войти в личный кабинет и лаунчер.": "Добро пожаловать в MoonWell! Подтверди почту, чтобы войти в личный кабинет и лаунчер.", + "Подтвердить email": "Подтвердить email", + "Ссылка действует 60 минут. Новое письмо можно запросить на странице подтверждения.": "Ссылка действует 60 минут. Новое письмо можно запросить на странице подтверждения.", + "Если ты не создавал аккаунт MoonWell, просто проигнорируй это письмо.": "Если ты не создавал аккаунт MoonWell, просто проигнорируй это письмо.", + "Восстановление пароля — MoonWell": "Восстановление пароля — MoonWell", + "Возвращение в MoonWell": "Возвращение в MoonWell", + "Задай новый пароль": "Задай новый пароль", + "Мы получили запрос на восстановление доступа к твоему аккаунту. Нажми кнопку, чтобы задать новый пароль для сайта и лаунчера.": "Мы получили запрос на восстановление доступа к твоему аккаунту. Нажми кнопку, чтобы задать новый пароль для сайта и лаунчера.", + "Восстановить пароль": "Восстановить пароль", + "Ссылка действует 60 минут и может быть использована только один раз.": "Ссылка действует 60 минут и может быть использована только один раз.", + "Если ты не запрашивал восстановление, просто проигнорируй письмо. Твой пароль останется прежним.": "Если ты не запрашивал восстановление, просто проигнорируй письмо. Твой пароль останется прежним.", + "Игрок": "Игрок", + "Модератор": "Модератор", + "Гейммастер": "Гейммастер", + "Администратор": "Администратор", + "Неизвестно": "Неизвестно", + "Человек": "Человек", + "Орк": "Орк", + "Дворф": "Дворф", + "Ночной эльф": "Ночной эльф", + "Нежить": "Нежить", + "Таурен": "Таурен", + "Гном": "Гном", + "Тролль": "Тролль", + "Воин": "Воин", + "Паладин": "Паладин", + "Охотник": "Охотник", + "Разбойник": "Разбойник", + "Жрец": "Жрец", + "Шаман": "Шаман", + "Маг": "Маг", + "Чернокнижник": "Чернокнижник", + "Друид": "Друид", + "Мужской": "Мужской", + "Женский": "Женский", + "%dз %dс %dм": "%dз %dс %dм", + "%dд %dч": "%dд %dч", + "%dч %dм": "%dч %dм", + "%dм": "%dм", + "Пополнение Слёз Элуны": "Пополнение Слёз Элуны", + "Некорректные параметры уведомления Robokassa.": "Некорректные параметры уведомления Robokassa.", + "Некорректная подпись Robokassa.": "Некорректная подпись Robokassa.", + "Сумма или получатель платежа не совпадают со счётом.": "Сумма или получатель платежа не совпадают со счётом.", + "Некорректная сумма.": "Некорректная сумма.", + "Некорректный курс Слёз Элуны.": "Некорректный курс Слёз Элуны.", + "Неизвестный алгоритм подписи Robokassa.": "Неизвестный алгоритм подписи Robokassa.", + "ROBOKASSA_MODE должен быть test или production.": "ROBOKASSA_MODE должен быть test или production.", + "Некорректный режим Robokassa.": "Некорректный режим Robokassa.", + "Начни своё приключение": "Начни своё приключение", + "Создай игровой аккаунт и установи клиент World of Warcraft": "Создай игровой аккаунт и установи клиент World of Warcraft", + "через лаунчер MoonWell.": "через лаунчер MoonWell.", + "Войти в личный кабинет": "Войти в личный кабинет", + "Новости сервера": "Новости сервера", + "Переход к оплате": "Переход к оплате", + "Переходим к пополнению…": "Переходим к пополнению…", + "Перейти к оплате": "Перейти к оплате", + "Привет,": "Привет,", + "Если кнопка не работает, скопируй ссылку в браузер:": "Если кнопка не работает, скопируй ссылку в браузер:", + "Это автоматическое письмо, отвечать на него не нужно.": "Это автоматическое письмо, отвечать на него не нужно.", + "Поддержка:": "Поддержка:", + ":name — PvP сервер WoW Vanilla :version :rate": ":name — PvP сервер WoW Vanilla :version :rate", + "Новости сервера WoW :version — :name": "Новости сервера WoW :version — :name", + "Публичная оферта — :name": "Публичная оферта — :name", + "Политика обработки персональных данных — :name": "Политика обработки персональных данных — :name", + "Правила проекта — :name": "Правила проекта — :name", + "Вход в личный кабинет — :name": "Вход в личный кабинет — :name", + "Восстановление пароля — :name": "Восстановление пароля — :name", + "Подтверждение email — :name": "Подтверждение email — :name", + "Личный кабинет — :name": "Личный кабинет — :name", + "Аккаунты и инвайты — :name": "Аккаунты и инвайты — :name", + "Слёзы Элуны — :name": "Слёзы Элуны — :name", + "Магазин — :name": "Магазин — :name", + "Новости — :name": "Новости — :name", + "Новости WoW — :name": "Новости WoW — :name", + ":name — русскоязычный PvP сервер World of Warcraft Vanilla :version с рейтами :rate. Классический Азерот, подземелья и рейды. Создай аккаунт и начни играть.": ":name — русскоязычный PvP сервер World of Warcraft Vanilla :version с рейтами :rate. Классический Азерот, подземелья и рейды. Создай аккаунт и начни играть.", + "Новости PvP сервера WoW Vanilla :version :name: обновления, события, патчноуты и объявления проекта.": "Новости PvP сервера WoW Vanilla :version :name: обновления, события, патчноуты и объявления проекта.", + "Публичная оферта о заключении договора возмездного оказания цифровых услуг :name.": "Публичная оферта о заключении договора возмездного оказания цифровых услуг :name.", + "Политика обработки и защиты данных пользователей :name.": "Политика обработки и защиты данных пользователей :name.", + "Запрещённые действия, меры ответственности и порядок обжалования санкций в проекте :name.": "Запрещённые действия, меры ответственности и порядок обжалования санкций в проекте :name.", + "Вход в личный кабинет игрового сервера :name.": "Вход в личный кабинет игрового сервера :name.", + "Персонажи, баланс и настройки игрового аккаунта :name.": "Персонажи, баланс и настройки игрового аккаунта :name.", + " — страница :page": " — страница :page", + "сервер World of Warcraft": "сервер World of Warcraft", + "Сумма списания должна быть больше нуля.": "Сумма списания должна быть больше нуля.", + "Ключ операции уже использован с другими параметрами.": "Ключ операции уже использован с другими параметрами.", + "Недостаточно средств.": "Недостаточно средств.", + "Некорректная корректировка баланса.": "Некорректная корректировка баланса.", + "Недостаточно средств для ручного списания.": "Недостаточно средств для ручного списания." +} diff --git a/openapi.json b/openapi.json index f9570a0..a7d8e93 100644 --- a/openapi.json +++ b/openapi.json @@ -87,7 +87,7 @@ "password": { "type": "string", "example": "secret123", - "maxLength": 16, + "maxLength": 255, "minLength": 3 } }, @@ -158,6 +158,9 @@ }, "429": { "description": "Слишком много запросов" + }, + "403": { + "description": "Требуется подтверждение email; verification_url содержит страницу повторной отправки письма" } } } @@ -659,7 +662,7 @@ "Launcher Auth" ], "summary": "Зарегистрировать игровой аккаунт", - "description": "Создаёт аккаунт сайта и vMaNGOS со случайным игровым паролем. Для входа в игру лаунчер выдаёт билет. Инвайт-код обязателен, если включена регистрация по приглашениям.", + "description": "Создаёт аккаунт сайта и vMaNGOS со случайным игровым паролем и отправляет письмо подтверждения email. Вход в сайт и лаунчер доступен после подтверждения. Для входа в игру лаунчер выдаёт билет. Инвайт-код обязателен, если включена регистрация по приглашениям.", "operationId": "bfc1dbd42cbcc339bc8a13dbf189c525", "requestBody": { "required": true, @@ -728,6 +731,15 @@ "type": "string", "example": "Игровой аккаунт успешно создан." }, + "verification_required": { + "type": "boolean", + "example": true + }, + "verification_url": { + "type": "string", + "format": "uri", + "example": "https://moon-well.online/email/verify" + }, "account": { "properties": { "id": { diff --git a/package.json b/package.json index c58904d..11c94df 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "type": "module", "scripts": { "build": "vite build", - "dev": "vite" + "dev": "vite", + "test:translations": "node tests/frontend/legal-translations.test.mjs" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", diff --git a/resources/css/moonwell.css b/resources/css/moonwell.css index 4a45af3..60a2a93 100644 --- a/resources/css/moonwell.css +++ b/resources/css/moonwell.css @@ -44,6 +44,19 @@ --t-med: 420ms cubic-bezier(.2,.7,.3,1); } +.language-switcher { flex-shrink: 0; margin: 0; } +.language-switcher select { + max-width: 112px; + padding: 8px 22px 8px 9px; + border: 1px solid var(--panel-border-strong); + border-radius: var(--radius); + background: var(--bg-1); + color: var(--ivory); + font: 11px var(--f-sans); + cursor: pointer; +} +.language-switcher select:focus-visible { outline: 2px solid var(--moon); outline-offset: 3px; } + /* Temple variant — cooler, more monumental, slightly more arcane/purple */ body[data-variant="temple"] { --bg-0: #0a0718; @@ -2671,6 +2684,18 @@ body[data-particles="false"] #particles { display: none; } } .portal-back-link:hover { color: var(--moon); } +.portal-access__hint { + margin: 18px 0 0; + font-size: 12px; + line-height: 1.7; + color: var(--text-mute); +} +.portal-access__verified { color: #6edc8e; } +.portal-access .portal-form-card > p { line-height: 1.8; overflow-wrap: anywhere; } +.portal-access .portal-submit { text-align: center; } +.portal-access input[readonly] { color: var(--text-soft); } +.portal-access .portal-submit:disabled { cursor: default; } + .portal-footer { margin-top: 30px; border-top: 1px solid var(--panel-border); diff --git a/resources/js/Components/LanguageSwitcher.vue b/resources/js/Components/LanguageSwitcher.vue new file mode 100644 index 0000000..2577d88 --- /dev/null +++ b/resources/js/Components/LanguageSwitcher.vue @@ -0,0 +1,15 @@ + + + diff --git a/resources/js/Components/LegalDocument.vue b/resources/js/Components/LegalDocument.vue index c31426a..842ea45 100644 --- a/resources/js/Components/LegalDocument.vue +++ b/resources/js/Components/LegalDocument.vue @@ -1,10 +1,11 @@