Livewire's temporary upload rule was hard-coded to max:4194304 (4 GB), so /livewire/upload-file rejected any larger file no matter how high PHP_UPLOAD_MAX_FILESIZE or the admin's max file size were set. _uploadErrored() then replaced Livewire's actual message with "file exceeds the maximum size of N MB", quoting the admin limit the file was under. The cap is removed: PHP's upload_max_filesize is the hard limit and the admin setting is still enforced in updatedFiles() and createShare(). A rejected upload now logs the real validation errors and tells the user the server could not accept the file. max_upload_time is configurable via LIVEWIRE_MAX_UPLOAD_TIME, and the README documents every limit large uploads depend on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017XYnWFt9pJEwvAmNFN38XD
203 lines
6.9 KiB
PHP
203 lines
6.9 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire;
|
|
|
|
use App\Models\Setting;
|
|
use App\Services\ShareService;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Component;
|
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
|
use Livewire\WithFileUploads;
|
|
|
|
#[Layout('layouts.app')]
|
|
class FileUploader extends Component
|
|
{
|
|
use WithFileUploads;
|
|
|
|
/** @var array<int, TemporaryUploadedFile> */
|
|
public array $files = [];
|
|
|
|
/** @var array<int, string|null> */
|
|
public array $relativePaths = [];
|
|
|
|
public bool $usePassword = false;
|
|
|
|
public string $password = '';
|
|
|
|
public string $password_confirmation = '';
|
|
|
|
public string $expiration = '7d';
|
|
|
|
public ?int $maxDownloads = null;
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->expiration = Setting::get('default_expiration', '7d') ?: '7d';
|
|
}
|
|
|
|
/**
|
|
* Handle an upload the temporary upload endpoint did not accept.
|
|
*
|
|
* Validation errors (a 422) mean the whole file reached the server and was
|
|
* rejected there, so the real reason is logged for the administrator rather
|
|
* than guessed at in front of the user. Anything else is a transport failure.
|
|
*/
|
|
public function _uploadErrored($name, $errorsInJson, $isMultiple): void
|
|
{
|
|
$this->dispatch('upload:errored', name: $name)->self();
|
|
|
|
$errors = is_null($errorsInJson) ? null : (json_decode($errorsInJson, true)['errors'] ?? null);
|
|
|
|
if ($errors) {
|
|
Log::warning('File upload rejected by the temporary upload endpoint.', ['errors' => $errors]);
|
|
|
|
throw ValidationException::withMessages([
|
|
'files' => __('Upload failed: the server could not accept the file. Please try again or contact the administrator.'),
|
|
]);
|
|
}
|
|
|
|
$maxFileSizeMb = (int) ((int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024));
|
|
|
|
throw ValidationException::withMessages([
|
|
'files' => __('Upload failed: file may be too large (max :max MB) or the connection was interrupted.', ['max' => $maxFileSizeMb]),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Validate a freshly uploaded batch of files.
|
|
*
|
|
* Dispatches `files-processed` so the front end can drop its "uploading" state.
|
|
* This runs for every batch, including additional files added to an existing
|
|
* selection, which a one-off `x-init` on the file list cannot cover.
|
|
*/
|
|
public function updatedFiles(): void
|
|
{
|
|
$this->dispatch('files-processed')->self();
|
|
|
|
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
|
$maxFileSizeMb = $maxFileSize / (1024 * 1024);
|
|
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
|
|
|
$this->resetErrorBag('files');
|
|
|
|
if (count($this->files) > $maxFilesPerShare) {
|
|
$this->addError('files', __('Too many files. Maximum :max files allowed per share.', ['max' => $maxFilesPerShare]));
|
|
|
|
return;
|
|
}
|
|
|
|
foreach ($this->files as $file) {
|
|
if ($file->getSize() > $maxFileSize) {
|
|
$this->addError('files', __('":name" is too large (:size MB). Maximum file size is :max MB.', [
|
|
'name' => $file->getClientOriginalName(),
|
|
'size' => round($file->getSize() / (1024 * 1024), 1),
|
|
'max' => (int) $maxFileSizeMb,
|
|
]));
|
|
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
public function removeFile(int $index): void
|
|
{
|
|
unset($this->files[$index], $this->relativePaths[$index]);
|
|
$this->files = array_values($this->files);
|
|
$this->relativePaths = array_values($this->relativePaths);
|
|
}
|
|
|
|
public function createShare(ShareService $shareService): void
|
|
{
|
|
$maxFilesPerShare = (int) Setting::get('max_files_per_share', 50);
|
|
$maxSizePerShare = (int) Setting::get('max_size_per_share', 2 * 1024 * 1024 * 1024);
|
|
$maxFileSize = (int) Setting::get('max_file_size', 100 * 1024 * 1024);
|
|
|
|
$rules = [
|
|
'files' => ['required', 'array', 'min:1', 'max:'.$maxFilesPerShare],
|
|
'files.*' => ['required', 'file', 'max:'.($maxFileSize / 1024)],
|
|
];
|
|
|
|
$allowNeverExpire = (bool) Setting::get('allow_never_expire', false);
|
|
|
|
if (! $allowNeverExpire) {
|
|
$rules['expiration'] = ['required', 'string', 'in:1h,24h,48h,7d,14d,30d'];
|
|
}
|
|
|
|
if ($this->usePassword) {
|
|
$rules['password'] = ['required', 'string', 'min:8'];
|
|
}
|
|
|
|
$this->validate($rules, [
|
|
'expiration.required' => __('An expiration time is required.'),
|
|
'files.required' => __('Please select at least one file to upload.'),
|
|
'files.max' => __('Too many files. Maximum :max files allowed per share.'),
|
|
'files.*.max' => __('A file exceeds the maximum size of :max KB.'),
|
|
]);
|
|
|
|
if ($shareService->isStorageFull()) {
|
|
$this->addError('files', __('Storage is full. Please contact the administrator.'));
|
|
|
|
return;
|
|
}
|
|
|
|
$totalSize = collect($this->files)->sum(fn ($file) => $file->getSize());
|
|
|
|
if ($totalSize > $maxSizePerShare) {
|
|
$this->addError('files', __('Total file size exceeds the maximum allowed per share.'));
|
|
|
|
return;
|
|
}
|
|
|
|
$fileData = [];
|
|
foreach ($this->files as $index => $file) {
|
|
$relativePath = $this->relativePaths[$index] ?? null;
|
|
|
|
if ($relativePath !== null) {
|
|
$relativePath = str_replace('\\', '/', $relativePath);
|
|
|
|
if (str_starts_with($relativePath, '/') || str_contains($relativePath, '..')) {
|
|
$relativePath = null;
|
|
}
|
|
}
|
|
|
|
$fileData[] = [
|
|
'file' => $file,
|
|
'relativePath' => $relativePath,
|
|
];
|
|
}
|
|
|
|
$expiresAt = match ($this->expiration) {
|
|
'1h' => now()->addHour(),
|
|
'24h' => now()->addDay(),
|
|
'48h' => now()->addDays(2),
|
|
'7d' => now()->addWeek(),
|
|
'14d' => now()->addDays(14),
|
|
'30d' => now()->addMonth(),
|
|
default => null,
|
|
};
|
|
|
|
$share = $shareService->createShare($fileData, [
|
|
'password' => $this->usePassword ? $this->password : null,
|
|
'expires_at' => $expiresAt,
|
|
'max_downloads' => $this->maxDownloads ?: null,
|
|
]);
|
|
|
|
$this->redirect(route('share.created', $share), navigate: true);
|
|
}
|
|
|
|
public function render(): mixed
|
|
{
|
|
$shareService = app(ShareService::class);
|
|
|
|
return view('livewire.file-uploader', [
|
|
'isStorageFull' => $shareService->isStorageFull(),
|
|
'siteTitle' => Setting::get('site_title'),
|
|
'siteDescription' => Setting::get('site_description'),
|
|
'siteLogo' => Setting::get('site_logo'),
|
|
'allowNeverExpire' => (bool) Setting::get('allow_never_expire', false),
|
|
]);
|
|
}
|
|
}
|