add temporary file manager
This commit is contained in:
@@ -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
|
||||
|
||||
82
app/Http/Controllers/FileLinkController.php
Normal file
82
app/Http/Controllers/FileLinkController.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class FileLinkController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
return view('files.index', [
|
||||
'enabled' => 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]),
|
||||
];
|
||||
}
|
||||
}
|
||||
9
config/file_links.php
Normal file
9
config/file_links.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'enabled' => (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),
|
||||
];
|
||||
|
||||
46
resources/views/files/index.blade.php
Normal file
46
resources/views/files/index.blade.php
Normal file
@@ -0,0 +1,46 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('content')
|
||||
<div class="bg-white p-6 rounded-xl shadow-md space-y-4">
|
||||
<h1 class="text-2xl font-bold text-gray-800">Временная ссылка на файл</h1>
|
||||
|
||||
@if (!$enabled)
|
||||
<div class="p-3 bg-yellow-50 text-yellow-800 rounded-lg text-sm">
|
||||
Функция отключена конфигом (`FILE_LINKS_ENABLED=false`).
|
||||
</div>
|
||||
@else
|
||||
@if ($errors->any())
|
||||
<div class="p-3 bg-red-50 text-red-700 rounded-lg text-sm">
|
||||
{{ $errors->first() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('file_link'))
|
||||
<div class="p-3 bg-green-50 text-green-800 rounded-lg text-sm break-all">
|
||||
<p class="mb-2"><strong>Ссылка готова:</strong></p>
|
||||
<p>
|
||||
<a href="{{ session('file_link') }}" class="text-blue-600 hover:underline" target="_blank">
|
||||
{{ session('file_link') }}
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2">Действует до: {{ session('file_expires_at') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('files.upload') }}" enctype="multipart/form-data" class="space-y-4">
|
||||
@csrf
|
||||
<div>
|
||||
<label for="file" class="block text-gray-700 mb-1">Выберите файл</label>
|
||||
<input id="file" type="file" name="file" required
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg">
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2.5 px-4 rounded-lg transition">
|
||||
Загрузить и получить ссылку
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||
<header class="border-b bg-white">
|
||||
<div class="container mx-auto px-4 py-3 max-w-2xl flex justify-between items-center">
|
||||
<a href="/" class="font-semibold text-gray-800">Poker Planning</a>
|
||||
<a href="{{ route('files.index') }}" class="text-sm text-blue-600 hover:underline">Файлы</a>
|
||||
</div>
|
||||
</header>
|
||||
<main class="container mx-auto px-4 py-8 max-w-2xl flex-grow">
|
||||
@yield('content')
|
||||
</main>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\HabitController;
|
||||
use App\Http\Controllers\FileLinkController;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -17,3 +18,11 @@ Route::prefix('habits')->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');
|
||||
});
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\PokerController;
|
||||
use App\Http\Controllers\FileLinkController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('welcome');
|
||||
});
|
||||
|
||||
Route::get('/files', [FileLinkController::class, 'index'])->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']);
|
||||
|
||||
53
tests/Feature/FileLinkControllerTest.php
Normal file
53
tests/Feature/FileLinkControllerTest.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FileLinkControllerTest extends TestCase
|
||||
{
|
||||
public function test_upload_is_unavailable_when_feature_is_disabled(): void
|
||||
{
|
||||
Config::set('file_links.enabled', false);
|
||||
|
||||
$response = $this->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');
|
||||
}
|
||||
}
|
||||
|
||||
38
tests/Feature/FileLinkWebTest.php
Normal file
38
tests/Feature/FileLinkWebTest.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FileLinkWebTest extends TestCase
|
||||
{
|
||||
public function test_files_page_shows_disabled_state(): void
|
||||
{
|
||||
Config::set('file_links.enabled', false);
|
||||
|
||||
$response = $this->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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user