diff --git a/README.md b/README.md index 8534ba8..e67f22e 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Laravel 12-приложение с WoW-лендингом и публичной - Добавлены личный кабинет и админка поверх игровых аккаунтов из auth-базы. - В кабинете доступны смена пароля, список персонажей и ссылка на клиент игры из S3. - В админке доступны просмотр всех аккаунтов, их персонажей, смена `gmlevel` и выпуск инвайт-кодов. +- В админке доступны новости для логин-экрана WoW 3.3.5a с публичным `SERVERALERT`-фидом для клиента. - Проект подготовлен под Laravel Sail: приложение работает в контейнере, а AzerothCore auth DB берётся с хоста через `host.docker.internal:3306`. ## Как запускать через Sail @@ -108,9 +109,16 @@ chmod +x scripts/deploy-ubuntu.sh - `app/Auth/AzerothCoreUserProvider.php` — Laravel-auth поверх игровых аккаунтов. - `app/Services/AzerothCoreAccountService.php` — работа с аккаунтами, персонажами, паролями и `gmlevel`. - `app/Services/GameClientDownloadService.php` — генерация ссылки на клиент из S3. +- `app/Services/LoginScreenNewsService.php` — сборка plain-text фида для логин-экрана WoW. - `app/Services/AzerothCoreAccountRegistrar.php` — SRP6-совместимое создание учётной записи AzerothCore. - `app/Http/Controllers/LandingController.php` — данные лендинга и обработка формы. +## Новости логин-экрана WoW + +- Админский раздел: `/admin/login-screen-news` +- Публичный фид для клиента: `/launcher/login-news.txt` +- Сайт сам добавляет префикс `SERVERALERT:` и склеивает опубликованные записи по `sort_order`. + ## Проверка Вне Sail я уже проверил: diff --git a/app/Http/Controllers/Admin/LoginScreenNewsController.php b/app/Http/Controllers/Admin/LoginScreenNewsController.php new file mode 100644 index 0000000..2eda7f2 --- /dev/null +++ b/app/Http/Controllers/Admin/LoginScreenNewsController.php @@ -0,0 +1,47 @@ + $news->listForAdmin(), + 'newsStats' => $news->stats(), + 'feedPreview' => $news->renderFeed(), + 'feedUrl' => route('launcher.login-news.show'), + ]); + } + + public function store(SaveLoginScreenNewsRequest $request, LoginScreenNewsService $news): RedirectResponse + { + $news->create($request->validated(), $request->user()); + + return back()->with('status', 'Новость для логин-экрана добавлена.'); + } + + public function update( + LoginScreenNews $newsItem, + SaveLoginScreenNewsRequest $request, + LoginScreenNewsService $news, + ): RedirectResponse { + $news->update($newsItem, $request->validated(), $request->user()); + + return back()->with('status', 'Новость для логин-экрана обновлена.'); + } + + public function destroy(LoginScreenNews $newsItem, LoginScreenNewsService $news): RedirectResponse + { + $news->delete($newsItem); + + return back()->with('status', 'Новость для логин-экрана удалена.'); + } +} diff --git a/app/Http/Controllers/WowLoginNewsFeedController.php b/app/Http/Controllers/WowLoginNewsFeedController.php new file mode 100644 index 0000000..8ff443c --- /dev/null +++ b/app/Http/Controllers/WowLoginNewsFeedController.php @@ -0,0 +1,17 @@ +renderFeed(), 200, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0', + ]); + } +} diff --git a/app/Http/Requests/SaveLoginScreenNewsRequest.php b/app/Http/Requests/SaveLoginScreenNewsRequest.php new file mode 100644 index 0000000..d883334 --- /dev/null +++ b/app/Http/Requests/SaveLoginScreenNewsRequest.php @@ -0,0 +1,50 @@ +merge([ + 'is_published' => $this->boolean('is_published'), + ]); + } + + /** + * @return array|string> + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:120'], + 'body' => ['required', 'string', 'max:20000'], + 'sort_order' => ['required', 'integer', 'min:0', 'max:9999'], + 'is_published' => ['nullable', 'boolean'], + ]; + } + + /** + * @return array + */ + 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' => 'Порядок вывода слишком большой.', + ]; + } +} diff --git a/app/Models/LoginScreenNews.php b/app/Models/LoginScreenNews.php new file mode 100644 index 0000000..1c0b547 --- /dev/null +++ b/app/Models/LoginScreenNews.php @@ -0,0 +1,39 @@ + 'bool', + ]; + } + + public function scopeOrdered(Builder $query): Builder + { + return $query + ->orderBy('sort_order') + ->orderByDesc('id'); + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('is_published', true); + } +} diff --git a/app/Services/LoginScreenNewsService.php b/app/Services/LoginScreenNewsService.php new file mode 100644 index 0000000..707ac88 --- /dev/null +++ b/app/Services/LoginScreenNewsService.php @@ -0,0 +1,117 @@ + + */ + public function listForAdmin(): Collection + { + return LoginScreenNews::query() + ->ordered() + ->get(); + } + + /** + * @return array{total: int, published: int, draft: int} + */ + public function stats(): array + { + $total = LoginScreenNews::query()->count(); + $published = LoginScreenNews::query()->published()->count(); + + return [ + 'total' => $total, + 'published' => $published, + 'draft' => $total - $published, + ]; + } + + /** + * @param array{title: string, body: string, sort_order: int, is_published?: bool} $attributes + */ + public function create(array $attributes, ?GameAccountUser $actor): LoginScreenNews + { + return LoginScreenNews::query()->create([ + ...$this->normalizeAttributes($attributes), + 'created_by_account_id' => $actor?->id, + 'created_by_username' => $actor?->username, + 'updated_by_account_id' => $actor?->id, + 'updated_by_username' => $actor?->username, + ]); + } + + /** + * @param array{title: string, body: string, sort_order: int, is_published?: bool} $attributes + */ + public function update(LoginScreenNews $newsItem, array $attributes, ?GameAccountUser $actor): LoginScreenNews + { + $newsItem->forceFill([ + ...$this->normalizeAttributes($attributes), + 'updated_by_account_id' => $actor?->id, + 'updated_by_username' => $actor?->username, + ])->save(); + + return $newsItem->refresh(); + } + + public function delete(LoginScreenNews $newsItem): void + { + $newsItem->delete(); + } + + public function renderFeed(): string + { + $sections = LoginScreenNews::query() + ->published() + ->ordered() + ->pluck('body') + ->map(fn (mixed $body): string => $this->normalizeBody((string) $body)) + ->filter(fn (string $body): bool => $body !== '') + ->values(); + + $payload = $sections->isNotEmpty() + ? $sections->implode("\n".self::SECTION_SEPARATOR."\n") + : self::EMPTY_MESSAGE; + + return self::FEED_HEADER."\n\n".$payload."\n"; + } + + /** + * @param array{title: string, body: string, sort_order: int, is_published?: bool} $attributes + * @return array{title: string, body: string, sort_order: int, is_published: bool} + */ + private function normalizeAttributes(array $attributes): array + { + return [ + 'title' => trim($attributes['title']), + 'body' => $this->normalizeBody($attributes['body']), + 'sort_order' => (int) $attributes['sort_order'], + 'is_published' => (bool) ($attributes['is_published'] ?? false), + ]; + } + + private function normalizeBody(string $body): string + { + $normalized = trim(str_replace(["\r\n", "\r"], "\n", $body)); + + if (Str::startsWith($normalized, self::FEED_HEADER)) { + $normalized = trim(Str::after($normalized, self::FEED_HEADER)); + } + + return $normalized; + } +} diff --git a/database/migrations/2026_03_15_000004_create_login_screen_news_table.php b/database/migrations/2026_03_15_000004_create_login_screen_news_table.php new file mode 100644 index 0000000..d0dca93 --- /dev/null +++ b/database/migrations/2026_03_15_000004_create_login_screen_news_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('title', 120); + $table->text('body'); + $table->unsignedSmallInteger('sort_order')->default(0)->index(); + $table->boolean('is_published')->default(true)->index(); + $table->unsignedInteger('created_by_account_id')->nullable()->index(); + $table->string('created_by_username', 32)->nullable(); + $table->unsignedInteger('updated_by_account_id')->nullable()->index(); + $table->string('updated_by_username', 32)->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('login_screen_news'); + } +}; diff --git a/phpunit.xml b/phpunit.xml index a85936a..e7f0a48 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,7 +23,8 @@ - + + diff --git a/public/site.css b/public/site.css index c7858a7..4bc666c 100644 --- a/public/site.css +++ b/public/site.css @@ -343,6 +343,14 @@ pre { box-shadow: 0 0 0 3px rgba(241, 200, 121, 0.1); } +.form-control--textarea { + min-height: 220px; + padding: 16px; + resize: vertical; + line-height: 1.6; + font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace; +} + .is-invalid { border-color: rgba(255, 143, 143, 0.6); } @@ -540,11 +548,51 @@ pre { gap: 16px; } +.stack-form, +.news-card { + display: grid; + gap: 16px; +} + +.checkbox-inline { + display: inline-flex; + align-items: center; + gap: 12px; + color: var(--muted); +} + +.checkbox-inline input { + margin: 0; +} + .muted-note { color: var(--muted); font-size: 0.92rem; } +.code-block, +.feed-preview { + margin: 0; + padding: 18px; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, 0.03); + color: var(--text); + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; + font-size: 0.92rem; + line-height: 1.65; +} + +.code-block { + display: block; +} + +.feed-preview--compact { + min-height: 100%; +} + .character-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -626,6 +674,12 @@ pre { align-items: end; } +.form-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + .inline-form .form-field { flex: 1 1 auto; } diff --git a/resources/views/admin/login-screen-news/index.blade.php b/resources/views/admin/login-screen-news/index.blade.php new file mode 100644 index 0000000..d32e785 --- /dev/null +++ b/resources/views/admin/login-screen-news/index.blade.php @@ -0,0 +1,165 @@ +@extends('layouts.portal') + +@section('portal-content') +
+
+ Админка +

Новости логин-экрана WoW

+

Публикуй raw-текст с WoW-разметкой, а сайт сам отдаст готовый `SERVERALERT`-фид по открытой ссылке для клиента 3.3.5a.

+
+ +
+
+

Публичная ссылка для клиента

+

Эту ссылку можно вставить в логин-скрин клиента. Ответ возвращается как plain text и доступен без авторизации.

+ +
+ Открыть фид + {{ $feedUrl }} +
+ +
+
+
Всего записей
+
{{ $newsStats['total'] }}
+
+
+
Опубликовано
+
{{ $newsStats['published'] }}
+
+
+
Черновиков
+
{{ $newsStats['draft'] }}
+
+
+
+ +
+

Итоговый ответ сервера

+

Это превью полного ответа, который получит клиент по публичному URL.

+ +
{{ $feedPreview }}
+
+
+
+ +
+
+ Новая запись +

Добавить новость

+

Каждая запись становится отдельным блоком в общем фиде. Префикс `SERVERALERT:` добавляется автоматически.

+ +
+ @csrf + + + + + + + + + + +
+
+ +
+ Шаблон +

Пример WoW-блока

+

Если нужно, можно вставлять уже готовые строки почти в том же виде, как у тебя в примере с GitHub raw.

+ +
|TInterface\ICONS\achievement_boss_lichking:25:25:0:-5|t |cFF00D1FFWrath of the Lich King|r
+|TInterface\ICONS\achievement_zone_icecrown_01:18:18:0:-8|t |cFFA6D8FF- Артас пробуждается как Король-лич|r
+|TInterface\ICONS\spell_deathknight_iceboundfortitude:18:18:0:-8|t |cFFA6D8FF- Рыцари смерти вторгаются в Азерот|r
+
+
+ +
+
+ Текущие записи +

Редактирование и публикация

+

Опубликованные записи попадают в фид по порядку `sort_order`. Черновики остаются только в админке.

+
+ + +
+@endsection diff --git a/resources/views/layouts/portal.blade.php b/resources/views/layouts/portal.blade.php index 252efdc..3c7eb18 100644 --- a/resources/views/layouts/portal.blade.php +++ b/resources/views/layouts/portal.blade.php @@ -18,7 +18,8 @@ @if ($portalUser) Личный кабинет @if ($portalUser->canAccessAdminPanel()) - Админка + Аккаунты + Новости WoW @endif @endif diff --git a/routes/web.php b/routes/web.php index 8c22301..0181b74 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,12 +2,15 @@ use App\Http\Controllers\Admin\AccountController; use App\Http\Controllers\Admin\InviteCodeController; +use App\Http\Controllers\Admin\LoginScreenNewsController; use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\CabinetController; use App\Http\Controllers\LandingController; +use App\Http\Controllers\WowLoginNewsFeedController; use Illuminate\Support\Facades\Route; Route::get('/', [LandingController::class, 'index'])->name('landing.index'); +Route::get('/launcher/login-news.txt', [WowLoginNewsFeedController::class, 'show'])->name('launcher.login-news.show'); Route::post('/register', [LandingController::class, 'register']) ->middleware('throttle:8,1') ->name('game-account.store'); @@ -33,4 +36,8 @@ Route::middleware(['auth', 'gamemaster']) Route::get('/accounts', [AccountController::class, 'index'])->name('accounts.index'); Route::patch('/accounts/{accountId}/access', [AccountController::class, 'updateAccess'])->name('accounts.access.update'); Route::post('/invite-codes', [InviteCodeController::class, 'store'])->name('invite-codes.store'); + Route::get('/login-screen-news', [LoginScreenNewsController::class, 'index'])->name('login-screen-news.index'); + Route::post('/login-screen-news', [LoginScreenNewsController::class, 'store'])->name('login-screen-news.store'); + Route::patch('/login-screen-news/{newsItem}', [LoginScreenNewsController::class, 'update'])->name('login-screen-news.update'); + Route::delete('/login-screen-news/{newsItem}', [LoginScreenNewsController::class, 'destroy'])->name('login-screen-news.destroy'); }); diff --git a/tests/Feature/LoginScreenNewsFeatureTest.php b/tests/Feature/LoginScreenNewsFeatureTest.php new file mode 100644 index 0000000..e196701 --- /dev/null +++ b/tests/Feature/LoginScreenNewsFeatureTest.php @@ -0,0 +1,162 @@ +create([ + 'title' => 'Draft', + 'body' => 'HIDDEN', + 'sort_order' => 1, + 'is_published' => false, + ]); + + LoginScreenNews::query()->create([ + 'title' => 'Late', + 'body' => "SERVERALERT:\n\n|cFF111111Late|r", + 'sort_order' => 20, + 'is_published' => true, + ]); + + LoginScreenNews::query()->create([ + 'title' => 'Early', + 'body' => '|cFF222222Early|r', + 'sort_order' => 10, + 'is_published' => true, + ]); + + $response = $this->get(route('launcher.login-news.show')); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'text/plain; charset=UTF-8'); + $response->assertContent( + LoginScreenNewsService::FEED_HEADER + ."\n\n|cFF222222Early|r\n" + .LoginScreenNewsService::SECTION_SEPARATOR + ."\n|cFF111111Late|r\n" + ); + } + + public function test_public_feed_returns_fallback_message_when_no_news_is_published(): void + { + LoginScreenNews::query()->create([ + 'title' => 'Draft', + 'body' => 'HIDDEN', + 'sort_order' => 0, + 'is_published' => false, + ]); + + $response = $this->get(route('launcher.login-news.show')); + + $response->assertOk(); + $response->assertContent( + LoginScreenNewsService::FEED_HEADER + ."\n\n" + .LoginScreenNewsService::EMPTY_MESSAGE + ."\n" + ); + } + + public function test_regular_player_cannot_open_admin_news_page(): void + { + $response = $this->actingAs($this->makeUser(0))->get(route('admin.login-screen-news.index')); + + $response->assertForbidden(); + } + + public function test_admin_can_open_admin_news_page(): void + { + LoginScreenNews::query()->create([ + 'title' => 'Wrath Update', + 'body' => '|cFF00D1FFWrath update|r', + 'sort_order' => 0, + 'is_published' => true, + ]); + + $response = $this->actingAs($this->makeUser(3))->get(route('admin.login-screen-news.index')); + + $response->assertOk(); + $response->assertSee('Новости логин-экрана WoW'); + $response->assertSee('Wrath Update'); + $response->assertSee(route('launcher.login-news.show'), false); + } + + public function test_admin_can_create_update_and_delete_news_item(): void + { + $admin = $this->makeUser(3); + + $createResponse = $this->from(route('admin.login-screen-news.index')) + ->actingAs($admin) + ->post(route('admin.login-screen-news.store'), [ + 'title' => 'Launch Notice', + 'body' => '|cFFFFD700Server launch tonight|r', + 'sort_order' => 5, + 'is_published' => '1', + ]); + + $createResponse->assertRedirect(route('admin.login-screen-news.index')); + $createResponse->assertSessionHas('status', 'Новость для логин-экрана добавлена.'); + $this->assertDatabaseHas('login_screen_news', [ + 'title' => 'Launch Notice', + 'sort_order' => 5, + 'is_published' => 1, + 'created_by_username' => 'ADMIN', + 'updated_by_username' => 'ADMIN', + ]); + + $newsItem = LoginScreenNews::query()->firstOrFail(); + + $updateResponse = $this->from(route('admin.login-screen-news.index')) + ->actingAs($admin) + ->patch(route('admin.login-screen-news.update', $newsItem), [ + 'title' => 'Launch Notice Updated', + 'body' => '|cFFFFD700Server launch moved to Friday|r', + 'sort_order' => 8, + ]); + + $updateResponse->assertRedirect(route('admin.login-screen-news.index')); + $updateResponse->assertSessionHas('status', 'Новость для логин-экрана обновлена.'); + $this->assertDatabaseHas('login_screen_news', [ + 'id' => $newsItem->id, + 'title' => 'Launch Notice Updated', + 'sort_order' => 8, + 'is_published' => 0, + ]); + + $deleteResponse = $this->from(route('admin.login-screen-news.index')) + ->actingAs($admin) + ->delete(route('admin.login-screen-news.destroy', $newsItem)); + + $deleteResponse->assertRedirect(route('admin.login-screen-news.index')); + $deleteResponse->assertSessionHas('status', 'Новость для логин-экрана удалена.'); + $this->assertDatabaseMissing('login_screen_news', [ + 'id' => $newsItem->id, + ]); + } + + private function makeUser(int $gmLevel): GameAccountUser + { + return new GameAccountUser( + id: 1, + username: 'ADMIN', + email: 'ADMIN@EXAMPLE.COM', + regMail: 'ADMIN@EXAMPLE.COM', + gmLevel: $gmLevel, + locked: false, + lastLoginAt: '2026-03-13 18:00:00', + joinedAt: '2026-03-12 12:00:00', + saltHex: str_repeat('AA', 32), + verifierHex: str_repeat('BB', 32), + ); + } +}