From 324d1ad75c23fc175101e17f9d78af3271d6bd1b Mon Sep 17 00:00:00 2001 From: sindoring Date: Sun, 22 Mar 2026 17:28:03 +0400 Subject: [PATCH] =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Admin/NewsController.php | 45 +++++ .../Api/LauncherNewsController.php | 66 ++++++++ app/Http/Requests/SaveNewsRequest.php | 53 ++++++ app/Models/News.php | 48 ++++++ app/Services/NewsService.php | 97 +++++++++++ .../2026_03_22_000001_create_news_table.php | 28 ++++ openapi.json | 98 ++++++++++- resources/views/admin/news/index.blade.php | 154 ++++++++++++++++++ routes/api.php | 2 + routes/web.php | 6 + scripts/deploy-ubuntu.sh | 3 + 11 files changed, 594 insertions(+), 6 deletions(-) create mode 100644 app/Http/Controllers/Admin/NewsController.php create mode 100644 app/Http/Controllers/Api/LauncherNewsController.php create mode 100644 app/Http/Requests/SaveNewsRequest.php create mode 100644 app/Models/News.php create mode 100644 app/Services/NewsService.php create mode 100644 database/migrations/2026_03_22_000001_create_news_table.php create mode 100644 resources/views/admin/news/index.blade.php diff --git a/app/Http/Controllers/Admin/NewsController.php b/app/Http/Controllers/Admin/NewsController.php new file mode 100644 index 0000000..116c5f9 --- /dev/null +++ b/app/Http/Controllers/Admin/NewsController.php @@ -0,0 +1,45 @@ + $news->listForAdmin(), + 'newsStats' => $news->stats(), + ]); + } + + public function store(SaveNewsRequest $request, NewsService $news): RedirectResponse + { + $news->create($request->validated(), $request->user(), $request->file('image')); + + return back()->with('status', 'Новость добавлена.'); + } + + public function update( + News $newsItem, + SaveNewsRequest $request, + NewsService $news, + ): RedirectResponse { + $news->update($newsItem, $request->validated(), $request->file('image')); + + return back()->with('status', 'Новость обновлена.'); + } + + public function destroy(News $newsItem, NewsService $news): RedirectResponse + { + $news->delete($newsItem); + + return back()->with('status', 'Новость удалена.'); + } +} diff --git a/app/Http/Controllers/Api/LauncherNewsController.php b/app/Http/Controllers/Api/LauncherNewsController.php new file mode 100644 index 0000000..38e3c6b --- /dev/null +++ b/app/Http/Controllers/Api/LauncherNewsController.php @@ -0,0 +1,66 @@ + []]], + responses: [ + new OA\Response( + response: 200, + description: 'Список новостей', + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'data', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'id', type: 'integer', example: 1), + new OA\Property(property: 'title', type: 'string', example: 'Обновление 1.2'), + new OA\Property(property: 'body', type: 'string', example: 'Описание обновления...'), + new OA\Property(property: 'image_url', type: 'string', format: 'uri', nullable: true, example: 'https://storage.yandexcloud.net/warcraft-client/news/update-1.2.jpg'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + ], + ), + ), + ], + ), + ), + new OA\Response( + response: 401, + description: 'Не авторизован', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Unauthenticated.'), + ], + ), + ), + ], + )] + public function index(): JsonResponse + { + $news = News::published() + ->ordered() + ->get() + ->map(fn (News $item) => [ + 'id' => $item->id, + 'title' => $item->title, + 'body' => $item->body, + 'image_url' => $item->image_url, + 'created_at' => $item->created_at->toIso8601String(), + ]); + + return response()->json(['data' => $news]); + } +} diff --git a/app/Http/Requests/SaveNewsRequest.php b/app/Http/Requests/SaveNewsRequest.php new file mode 100644 index 0000000..7777ef9 --- /dev/null +++ b/app/Http/Requests/SaveNewsRequest.php @@ -0,0 +1,53 @@ +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'], + 'image' => ['nullable', 'image', 'max:2048'], + ]; + } + + /** + * @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' => 'Порядок вывода слишком большой.', + 'image.image' => 'Файл должен быть изображением.', + 'image.max' => 'Изображение не должно превышать 2 МБ.', + ]; + } +} diff --git a/app/Models/News.php b/app/Models/News.php new file mode 100644 index 0000000..8cc5644 --- /dev/null +++ b/app/Models/News.php @@ -0,0 +1,48 @@ + '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); + } + + public function getImageUrlAttribute(): ?string + { + if (! $this->image_path) { + return null; + } + + return Storage::disk('public')->url($this->image_path); + } +} diff --git a/app/Services/NewsService.php b/app/Services/NewsService.php new file mode 100644 index 0000000..1ae6f8a --- /dev/null +++ b/app/Services/NewsService.php @@ -0,0 +1,97 @@ + + */ + public function listForAdmin(): Collection + { + return News::query()->ordered()->get(); + } + + /** + * @return array{total: int, published: int, draft: int} + */ + public function stats(): array + { + $total = News::query()->count(); + $published = News::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, ?\Illuminate\Http\UploadedFile $image = null): News + { + $data = [ + ...$this->normalizeAttributes($attributes), + 'created_by_account_id' => $actor?->id, + 'created_by_username' => $actor?->username, + ]; + + if ($image) { + $data['image_path'] = $image->store('news', 'public'); + } + + return News::query()->create($data); + } + + /** + * @param array{title: string, body: string, sort_order: int, is_published?: bool} $attributes + */ + public function update(News $news, array $attributes, ?\Illuminate\Http\UploadedFile $image = null): News + { + $data = $this->normalizeAttributes($attributes); + + if ($image) { + if ($news->image_path) { + Storage::disk('public')->delete($news->image_path); + } + + $data['image_path'] = $image->store('news', 'public'); + } + + $news->forceFill($data)->save(); + + return $news->refresh(); + } + + public function delete(News $news): void + { + if ($news->image_path) { + Storage::disk('public')->delete($news->image_path); + } + + $news->delete(); + } + + /** + * @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' => trim($attributes['body']), + 'sort_order' => (int) $attributes['sort_order'], + 'is_published' => (bool) ($attributes['is_published'] ?? false), + ]; + } + + +} diff --git a/database/migrations/2026_03_22_000001_create_news_table.php b/database/migrations/2026_03_22_000001_create_news_table.php new file mode 100644 index 0000000..8fcb00a --- /dev/null +++ b/database/migrations/2026_03_22_000001_create_news_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('title', 120); + $table->text('body'); + $table->string('image_path')->nullable(); + $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->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('news'); + } +}; diff --git a/openapi.json b/openapi.json index 3e2bb09..2685078 100644 --- a/openapi.json +++ b/openapi.json @@ -197,8 +197,8 @@ "tags": [ "Launcher" ], - "summary": "Скачать файл клиента", - "description": "Стримит файл из S3 по относительному пути из манифеста. Путь может содержать вложенные директории (например `Data/ruRU/patch-ruRU-3.MPQ`).", + "summary": "Получить ссылку на скачивание файла", + "description": "Возвращает временную presigned-ссылку на файл в S3. Лаунчер скачивает файл напрямую из хранилища по этой ссылке. Путь может содержать вложенные директории (например `Data/ruRU/patch-ruRU-3.MPQ`).", "operationId": "9a94331833a7af795c1c57dfa3a77eb3", "parameters": [ { @@ -214,12 +214,23 @@ ], "responses": { "200": { - "description": "Бинарное содержимое файла", + "description": "Временная ссылка на скачивание", "content": { - "application/octet-stream": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "properties": { + "url": { + "description": "Presigned URL для скачивания", + "type": "string", + "format": "uri" + }, + "expires_in": { + "description": "Время жизни ссылки в секундах", + "type": "integer", + "example": 3600 + } + }, + "type": "object" } } } @@ -263,6 +274,81 @@ } ] } + }, + "/api/launcher/news": { + "get": { + "tags": [ + "Launcher" + ], + "summary": "Список новостей лаунчера", + "description": "Возвращает опубликованные новости для отображения в лаунчере. Отсортированы по sort_order и дате создания.", + "operationId": "69aa09e0c7cca466e8a45a186444245e", + "responses": { + "200": { + "description": "Список новостей", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "title": { + "type": "string", + "example": "Обновление 1.2" + }, + "body": { + "type": "string", + "example": "Описание обновления..." + }, + "image_url": { + "type": "string", + "format": "uri", + "example": "https://storage.yandexcloud.net/warcraft-client/news/update-1.2.jpg", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Не авторизован", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Unauthenticated." + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } } }, "components": { diff --git a/resources/views/admin/news/index.blade.php b/resources/views/admin/news/index.blade.php new file mode 100644 index 0000000..4145b7f --- /dev/null +++ b/resources/views/admin/news/index.blade.php @@ -0,0 +1,154 @@ +@extends('layouts.portal') + +@section('portal-content') +
+
+ Админка +

Новости

+

Новости отображаются в лаунчере. Картинка загружается в S3-хранилище.

+
+ +
+
+

Статистика

+ +
+
+
Всего
+
{{ $newsStats['total'] }}
+
+
+
Опубликовано
+
{{ $newsStats['published'] }}
+
+
+
Черновиков
+
{{ $newsStats['draft'] }}
+
+
+
+
+
+ +
+
+ Новая запись +

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

+ +
+ @csrf + + + + + + + + + + + + +
+
+
+ +
+
+ Текущие записи +

Редактирование

+
+ + +
+@endsection diff --git a/routes/api.php b/routes/api.php index 0729e53..15e7f60 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Api\LauncherAuthController; use App\Http\Controllers\Api\LauncherController; +use App\Http\Controllers\Api\LauncherNewsController; use Illuminate\Support\Facades\Route; Route::post('launcher/login', [LauncherAuthController::class, 'login']) @@ -11,4 +12,5 @@ Route::middleware('auth:api')->prefix('launcher')->group(function () { Route::get('manifest', [LauncherController::class, 'manifest']); Route::get('download/{path}', [LauncherController::class, 'download']) ->where('path', '.*'); + Route::get('news', [LauncherNewsController::class, 'index']); }); diff --git a/routes/web.php b/routes/web.php index 0181b74..1cfd437 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Admin\AccountController; use App\Http\Controllers\Admin\InviteCodeController; use App\Http\Controllers\Admin\LoginScreenNewsController; +use App\Http\Controllers\Admin\NewsController; use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\CabinetController; use App\Http\Controllers\LandingController; @@ -40,4 +41,9 @@ Route::middleware(['auth', 'gamemaster']) 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'); + + Route::get('/news', [NewsController::class, 'index'])->name('news.index'); + Route::post('/news', [NewsController::class, 'store'])->name('news.store'); + Route::patch('/news/{newsItem}', [NewsController::class, 'update'])->name('news.update'); + Route::delete('/news/{newsItem}', [NewsController::class, 'destroy'])->name('news.destroy'); }); diff --git a/scripts/deploy-ubuntu.sh b/scripts/deploy-ubuntu.sh index cdd66cf..8a69d43 100755 --- a/scripts/deploy-ubuntu.sh +++ b/scripts/deploy-ubuntu.sh @@ -214,6 +214,9 @@ main() { log "Сгенерированы ключи Passport" fi + chmod 600 "${ROOT_DIR}/storage/oauth-private.key" "${ROOT_DIR}/storage/oauth-public.key" + log "Установлены права 600 на ключи Passport" + if ! app_exec php artisan passport:client --personal --no-interaction 2>/dev/null | grep -q 'already exists'; then app_exec php artisan passport:client --personal --name="Launcher" --no-interaction log "Создан Personal Access Client «Launcher»"