add temporary file manager
This commit is contained in:
@@ -63,3 +63,8 @@ AWS_BUCKET=
|
|||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
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'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
<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">
|
<main class="container mx-auto px-4 py-8 max-w-2xl flex-grow">
|
||||||
@yield('content')
|
@yield('content')
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\HabitController;
|
use App\Http\Controllers\HabitController;
|
||||||
|
use App\Http\Controllers\FileLinkController;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -17,3 +18,11 @@ Route::prefix('habits')->group(function () {
|
|||||||
Route::patch('/currentCount', [HabitController::class, 'currentCountUpdate']);
|
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
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\PokerController;
|
use App\Http\Controllers\PokerController;
|
||||||
|
use App\Http\Controllers\FileLinkController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return view('welcome');
|
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::get('/s/{token}', [PokerController::class, 'showForm'])->name('vote.form');
|
||||||
Route::post('/s/{token}', [PokerController::class, 'submitVote']);
|
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