diff --git a/.env.example b/.env.example index c0660ea..2c93af3 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,8 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +FILE_LINKS_ENABLED=false +FILE_LINKS_DISK=local +FILE_LINKS_DIRECTORY=temporary-links +FILE_LINKS_MAX_SIZE_KB=10240 diff --git a/app/Http/Controllers/FileLinkController.php b/app/Http/Controllers/FileLinkController.php new file mode 100644 index 0000000..1c71601 --- /dev/null +++ b/app/Http/Controllers/FileLinkController.php @@ -0,0 +1,82 @@ + config('file_links.enabled'), + ]); + } + + public function uploadWeb(Request $request) + { + if (!config('file_links.enabled')) { + return redirect()->route('files.index')->withErrors(['file' => 'Функционал отключен']); + } + + $data = $this->storeAndBuildTemporaryLink($request); + + return redirect()->route('files.index')->with([ + 'file_link' => $data['url'], + 'file_expires_at' => $data['expiresAt'], + ]); + } + + public function upload(Request $request): JsonResponse + { + if (!config('file_links.enabled')) { + return response()->json(['message' => 'Функционал отключен'], Response::HTTP_NOT_FOUND); + } + + $data = $this->storeAndBuildTemporaryLink($request); + + return response()->json([ + 'path' => $data['path'], + 'expiresAt' => $data['expiresAt'], + 'url' => $data['url'], + ]); + } + + public function download(string $path) + { + if (!config('file_links.enabled')) { + abort(Response::HTTP_NOT_FOUND); + } + + $disk = config('file_links.disk'); + + if (!Storage::disk($disk)->exists($path)) { + abort(Response::HTTP_NOT_FOUND); + } + + return Storage::disk($disk)->download($path); + } + + private function storeAndBuildTemporaryLink(Request $request): array + { + $request->validate([ + 'file' => 'required|file|max:' . config('file_links.max_size_kb'), + ]); + + $disk = config('file_links.disk'); + $directory = config('file_links.directory'); + $path = $request->file('file')->store($directory, $disk); + $expiresAt = now()->addHour(); + + return [ + 'path' => $path, + 'expiresAt' => $expiresAt->toIso8601String(), + 'url' => URL::temporarySignedRoute('files.download', $expiresAt, ['path' => $path]), + ]; + } +} diff --git a/config/file_links.php b/config/file_links.php new file mode 100644 index 0000000..b8e77e2 --- /dev/null +++ b/config/file_links.php @@ -0,0 +1,9 @@ + (bool) env('FILE_LINKS_ENABLED', false), + 'disk' => env('FILE_LINKS_DISK', 'local'), + 'directory' => env('FILE_LINKS_DIRECTORY', 'temporary-links'), + 'max_size_kb' => (int) env('FILE_LINKS_MAX_SIZE_KB', 10240), +]; + diff --git a/resources/views/files/index.blade.php b/resources/views/files/index.blade.php new file mode 100644 index 0000000..26ab8a5 --- /dev/null +++ b/resources/views/files/index.blade.php @@ -0,0 +1,46 @@ +@extends('layouts.app') + +@section('content') +
+

Временная ссылка на файл

+ + @if (!$enabled) +
+ Функция отключена конфигом (`FILE_LINKS_ENABLED=false`). +
+ @else + @if ($errors->any()) +
+ {{ $errors->first() }} +
+ @endif + + @if (session('file_link')) +
+

Ссылка готова:

+

+ + {{ session('file_link') }} + +

+

Действует до: {{ session('file_expires_at') }}

+
+ @endif + +
+ @csrf +
+ + +
+ + +
+ @endif +
+@endsection + diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index fc308d4..2285d76 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -7,6 +7,12 @@ @vite(['resources/css/app.css', 'resources/js/app.js']) +
+
+ Poker Planning + Файлы +
+
@yield('content')
diff --git a/routes/api.php b/routes/api.php index ec866b1..4366412 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ group(function () { Route::patch('/currentCount', [HabitController::class, 'currentCountUpdate']); }); }); + +Route::prefix('files')->group(function () { + Route::post('/upload', [FileLinkController::class, 'upload']); + Route::get('/download/{path}', [FileLinkController::class, 'download']) + ->where('path', '.*') + ->middleware('signed') + ->name('files.api.download'); +}); diff --git a/routes/web.php b/routes/web.php index bbb5ff0..b47faac 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,12 +1,20 @@ name('files.index'); +Route::post('/files', [FileLinkController::class, 'uploadWeb'])->name('files.upload'); +Route::get('/files/download/{path}', [FileLinkController::class, 'download']) + ->where('path', '.*') + ->middleware('signed') + ->name('files.download'); + // Публичные маршруты Route::get('/s/{token}', [PokerController::class, 'showForm'])->name('vote.form'); Route::post('/s/{token}', [PokerController::class, 'submitVote']); diff --git a/tests/Feature/FileLinkControllerTest.php b/tests/Feature/FileLinkControllerTest.php new file mode 100644 index 0000000..ab7f19f --- /dev/null +++ b/tests/Feature/FileLinkControllerTest.php @@ -0,0 +1,53 @@ +postJson('/api/files/upload', [ + 'file' => UploadedFile::fake()->create('sample.txt', 1), + ]); + + $response->assertNotFound(); + } + + public function test_it_uploads_file_and_downloads_it_via_temporary_signed_link(): void + { + Storage::fake('local'); + Config::set('file_links.enabled', true); + Config::set('file_links.disk', 'local'); + Config::set('file_links.directory', 'temporary-links'); + + $response = $this->postJson('/api/files/upload', [ + 'file' => UploadedFile::fake()->create('sample.txt', 1), + ]); + + $response->assertOk(); + $response->assertJsonStructure(['path', 'expiresAt', 'url']); + + $path = $response->json('path'); + Storage::disk('local')->assertExists($path); + + $url = $response->json('url'); + $parts = parse_url($url); + $routePath = $parts['path']; + if (isset($parts['query'])) { + $routePath .= '?' . $parts['query']; + } + + $downloadResponse = $this->get($routePath); + + $downloadResponse->assertOk(); + $downloadResponse->assertHeader('content-disposition'); + } +} + diff --git a/tests/Feature/FileLinkWebTest.php b/tests/Feature/FileLinkWebTest.php new file mode 100644 index 0000000..a41586d --- /dev/null +++ b/tests/Feature/FileLinkWebTest.php @@ -0,0 +1,38 @@ +get('/files'); + + $response->assertOk(); + $response->assertSee('Функция отключена конфигом'); + } + + public function test_it_uploads_file_from_web_form_and_returns_temporary_link(): void + { + Storage::fake('local'); + Config::set('file_links.enabled', true); + Config::set('file_links.disk', 'local'); + Config::set('file_links.directory', 'temporary-links'); + + $response = $this->post('/files', [ + 'file' => UploadedFile::fake()->create('web-sample.txt', 1), + ]); + + $response->assertRedirect('/files'); + $response->assertSessionHas('file_link'); + $response->assertSessionHas('file_expires_at'); + } +} +