add temporary file manager

This commit is contained in:
2026-02-19 19:47:42 +03:00
parent cf635f81dd
commit ad4a3367c3
9 changed files with 256 additions and 0 deletions

View File

@@ -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

View 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
View 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),
];

View 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

View File

@@ -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>

View File

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

View File

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

View 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');
}
}

View 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');
}
}