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
+
+
+ @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'])
+
@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');
+ }
+}
+