A 29 GB share with 254 files failed as one ZIP: the single request ran into the 300-second request limit and, behind a proxy we cannot configure, could be cut at any time with no way to resume. Single-file downloads now answer HTTP Range requests (206, ETag and If-Range, 416 past the end): a SEALCHK2 file is decrypted from the chunk holding the first byte asked for. On that, the download page's new "Download all to a folder" (File System Access API, Chrome and Edge on a computer) saves every file into a folder the recipient picks, one request per file, in its folder path; a broken connection is retried from the byte on disk, complete files are skipped, and failed files get a Retry button. Other browsers keep the ZIP and the per-file buttons. The ZIP is sized up front with ZipStream's strict simulation and sent with Content-Length, so the browser shows progress and notices a cut. Both download routes lift the time limit for their streams. The recipient's download window now restarts with each download, so a long file-by-file download never outlasts it. The download page shows the file count and total size and marks the files this browser has downloaded, and the upload page gains "Browse Folder", which keeps each file's path as a dropped folder does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
105 lines
3.9 KiB
PHP
105 lines
3.9 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire;
|
|
|
|
use App\Models\Share;
|
|
use App\Services\ShareService;
|
|
use Carbon\CarbonInterval;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\Renderless;
|
|
use Livewire\Attributes\Validate;
|
|
use Livewire\Component;
|
|
|
|
#[Layout('layouts.app')]
|
|
class ShareDownload extends Component
|
|
{
|
|
public Share $share;
|
|
|
|
public bool $authenticated = false;
|
|
|
|
#[Validate('required|string')]
|
|
public string $password = '';
|
|
|
|
public function mount(Share $share, ShareService $shareService): void
|
|
{
|
|
$this->share = $share->load('files');
|
|
|
|
// A share at its download limit stays open for the recipient who took its last download.
|
|
if (! $share->isCompleted() || $share->isExpired()
|
|
|| ($share->hasReachedDownloadLimit() && $shareService->downloadWindowEndsAt($share, session()->driver()) === null)) {
|
|
abort(404);
|
|
}
|
|
|
|
$this->authenticated = ! $share->isPasswordProtected() || (bool) session('share_key_'.$share->token);
|
|
}
|
|
|
|
public function verifyPassword(ShareService $shareService): void
|
|
{
|
|
$rateLimitKey = 'share-password:'.$this->share->token.'|'.request()->ip();
|
|
|
|
if (RateLimiter::tooManyAttempts($rateLimitKey, 5)) {
|
|
$seconds = RateLimiter::availableIn($rateLimitKey);
|
|
$this->addError('password', __('Too many attempts. Please try again in :seconds seconds.', ['seconds' => $seconds]));
|
|
|
|
return;
|
|
}
|
|
|
|
$this->validate();
|
|
|
|
if (! $shareService->verifyPassword($this->share, $this->password)) {
|
|
RateLimiter::hit($rateLimitKey, 60);
|
|
$this->addError('password', __('The password is incorrect.'));
|
|
|
|
return;
|
|
}
|
|
|
|
RateLimiter::clear($rateLimitKey);
|
|
|
|
$encryptionKey = $shareService->getDecryptionKey($this->share, $this->password);
|
|
session(['share_key_'.$this->share->token => $encryptionKey]);
|
|
$this->authenticated = true;
|
|
}
|
|
|
|
/**
|
|
* The share's private text, for the recipient who pressed "Show text": counted as their
|
|
* download, as a file would be. Returned to the browser only, never kept in a property, so it
|
|
* is not in the component's snapshot. Null when the text is no longer available; no abort(),
|
|
* which would open Livewire's error modal. Renderless: a re-render would morph the download
|
|
* note Alpine just switched back into the one it hid.
|
|
*/
|
|
#[Renderless]
|
|
public function revealText(ShareService $shareService): ?string
|
|
{
|
|
$share = $this->share;
|
|
|
|
if (! $share->isCompleted() || $share->isExpired() || ! $share->hasText()
|
|
|| ($share->isPasswordProtected() && ! session('share_key_'.$share->token))
|
|
|| ! $shareService->claimDownload($share, session()->driver())) {
|
|
return null;
|
|
}
|
|
|
|
return $shareService->readText($share, $shareService->sessionDecryptionKey($share, session()->driver()));
|
|
}
|
|
|
|
public function render(): mixed
|
|
{
|
|
$shareService = app(ShareService::class);
|
|
$files = $this->share->files->where('is_text', false)->values();
|
|
|
|
return view('livewire.share-download', [
|
|
'files' => $files,
|
|
'downloads' => $files->map(fn ($file): array => [
|
|
'id' => $file->id,
|
|
'path' => $file->relative_path ?: $file->original_name,
|
|
'size' => $file->file_size,
|
|
'url' => route('share.download.file', [$this->share, $file]),
|
|
]),
|
|
'hasText' => $this->share->files->contains('is_text', true),
|
|
'downloadWindowEndsAt' => $shareService->downloadWindowEndsAt($this->share, session()->driver()),
|
|
'remainingDownloads' => $this->share->max_downloads ? max($this->share->max_downloads - $this->share->download_count, 0) : null,
|
|
'downloadWindow' => CarbonInterval::minutes(ShareService::DOWNLOAD_WINDOW_MINUTES)->cascade()->forHumans(),
|
|
]);
|
|
}
|
|
}
|