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>
486 lines
18 KiB
PHP
486 lines
18 KiB
PHP
<?php
|
|
|
|
use App\Livewire\ShareDownload;
|
|
use App\Models\Share;
|
|
use App\Models\ShareFile;
|
|
use App\Services\ShareService;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Livewire\Livewire;
|
|
|
|
test('share download page renders for valid share', function () {
|
|
Storage::fake('shares');
|
|
|
|
$share = createShareWithFile();
|
|
|
|
$response = $this->get(route('share.download', $share));
|
|
|
|
$response->assertOk();
|
|
});
|
|
|
|
test('share download page says how many files the share holds and their size', function () {
|
|
Storage::fake('shares');
|
|
$share = createShareWithFiles();
|
|
|
|
$response = $this->get(route('share.download', $share));
|
|
|
|
$response->assertSee('3 files, 31 B');
|
|
});
|
|
|
|
test('share download page returns 404 for expired share', function () {
|
|
$share = Share::factory()->expired()->create();
|
|
|
|
$response = $this->get(route('share.download', $share));
|
|
|
|
$response->assertNotFound();
|
|
});
|
|
|
|
test('share download page returns 404 when download limit reached', function () {
|
|
$share = Share::factory()->withMaxDownloads(1)->create(['download_count' => 1]);
|
|
|
|
$response = $this->get(route('share.download', $share));
|
|
|
|
$response->assertNotFound();
|
|
});
|
|
|
|
test('share download page shows password form for password-protected share', function () {
|
|
Storage::fake('shares');
|
|
|
|
$share = createShareWithFile('secret-pass');
|
|
|
|
$response = $this->get(route('share.download', $share));
|
|
|
|
$response->assertOk();
|
|
$response->assertSee('password');
|
|
});
|
|
|
|
test('password verification works for protected share', function () {
|
|
Storage::fake('shares');
|
|
|
|
$share = createShareWithFile('my-password');
|
|
|
|
Livewire::test(ShareDownload::class, ['share' => $share])
|
|
->assertSet('authenticated', false)
|
|
->set('password', 'my-password')
|
|
->call('verifyPassword')
|
|
->assertSet('authenticated', true)
|
|
->assertHasNoErrors();
|
|
});
|
|
|
|
test('wrong password is rejected', function () {
|
|
Storage::fake('shares');
|
|
|
|
$share = createShareWithFile('my-password');
|
|
|
|
Livewire::test(ShareDownload::class, ['share' => $share])
|
|
->set('password', 'wrong-password')
|
|
->call('verifyPassword')
|
|
->assertSet('authenticated', false)
|
|
->assertHasErrors(['password']);
|
|
});
|
|
|
|
test('non-password share shows files directly', function () {
|
|
Storage::fake('shares');
|
|
|
|
$share = createShareWithFile();
|
|
|
|
Livewire::test(ShareDownload::class, ['share' => $share])
|
|
->assertSet('authenticated', true);
|
|
});
|
|
|
|
test('download counter increments on zip download', function () {
|
|
Storage::fake('shares');
|
|
$share = createShareWithFile();
|
|
|
|
$response = $this->get(route('share.download.all', $share));
|
|
$response->streamedContent();
|
|
|
|
$response->assertDownload('share-'.$share->token.'.zip');
|
|
expect($share->fresh()->download_count)->toBe(1);
|
|
});
|
|
|
|
test('zip download streams a valid archive with every file\'s original content', function () {
|
|
Storage::fake('shares');
|
|
config(['uploads.chunk_size' => 1000]);
|
|
$binary = random_bytes(2500);
|
|
$share = app(ShareService::class)->createShare([
|
|
['file' => UploadedFile::fake()->createWithContent('notes.txt', 'hello zip content'), 'relativePath' => null],
|
|
['file' => UploadedFile::fake()->createWithContent('photo.bin', $binary), 'relativePath' => 'holiday/photo.bin'],
|
|
]);
|
|
$zipPath = tempnam(sys_get_temp_dir(), 'zip');
|
|
|
|
$response = $this->get(route('share.download.all', $share));
|
|
file_put_contents($zipPath, $content = $response->streamedContent());
|
|
|
|
$response->assertHeader('Content-Length', strlen($content));
|
|
|
|
$zip = new ZipArchive;
|
|
expect($zip->open($zipPath))->toBeTrue();
|
|
expect($zip->numFiles)->toBe(2);
|
|
expect($zip->getFromName('notes.txt'))->toBe('hello zip content');
|
|
expect($zip->getFromName('holiday/photo.bin'))->toBe($binary);
|
|
$zip->close();
|
|
unlink($zipPath);
|
|
});
|
|
|
|
test('a range request gets the file from that byte on, decrypted across chunks', function (string $range, int $start, int $end) {
|
|
Storage::fake('shares');
|
|
config(['uploads.chunk_size' => 1000]);
|
|
$binary = random_bytes(2500);
|
|
$share = app(ShareService::class)->createShare([
|
|
['file' => UploadedFile::fake()->createWithContent('photo.bin', $binary), 'relativePath' => null],
|
|
]);
|
|
|
|
$response = $this->get(route('share.download.file', [$share, $share->files->first()]), ['Range' => $range]);
|
|
|
|
$response->assertStatus(206)
|
|
->assertHeader('Content-Range', "bytes {$start}-{$end}/2500")
|
|
->assertHeader('Content-Length', $end - $start + 1);
|
|
expect($response->streamedContent())->toBe(substr($binary, $start, $end - $start + 1));
|
|
})->with([
|
|
'to the end' => ['bytes=1500-', 1500, 2499],
|
|
'across a chunk boundary' => ['bytes=999-1000', 999, 1000],
|
|
'the last bytes' => ['bytes=-10', 2490, 2499],
|
|
]);
|
|
|
|
test('a range request for another version of the file gets the whole file', function () {
|
|
Storage::fake('shares');
|
|
$share = createShareWithFile(content: 'whole file');
|
|
|
|
$response = $this->get(route('share.download.file', [$share, $share->files->first()]), ['Range' => 'bytes=6-', 'If-Range' => '"other"']);
|
|
|
|
$response->assertOk()->assertHeader('Accept-Ranges', 'bytes');
|
|
expect($response->streamedContent())->toBe('whole file');
|
|
});
|
|
|
|
test('a range past the end of the file is refused and uses no download', function () {
|
|
Storage::fake('shares');
|
|
$share = createShareWithFile(content: 'short');
|
|
|
|
$response = $this->get(route('share.download.file', [$share, $share->files->first()]), ['Range' => 'bytes=5-']);
|
|
|
|
$response->assertStatus(416)->assertHeader('Content-Range', 'bytes */5');
|
|
expect($share->fresh()->download_count)->toBe(0);
|
|
});
|
|
|
|
test('the last download streams and the share stays for the cleanup, with the time of that download', function () {
|
|
Storage::fake('shares');
|
|
$this->freezeSecond();
|
|
$share = createShareWithFile();
|
|
$share->update(['max_downloads' => 1]);
|
|
|
|
$content = $this->get(route('share.download.all', $share))->streamedContent();
|
|
|
|
expect($content)->toStartWith("PK\x03\x04");
|
|
expect($share->fresh()->last_downloaded_at)->toEqual(now());
|
|
});
|
|
|
|
test('a recipient downloads every file and the zip of a share limited to one download, which counts once', function () {
|
|
Storage::fake('shares');
|
|
$share = createShareWithFiles(maxDownloads: 1);
|
|
[$first, $second, $third] = $share->files->all();
|
|
|
|
$files = [
|
|
$this->get(route('share.download.file', [$share, $first]))->streamedContent(),
|
|
$this->get(route('share.download.file', [$share, $second]))->streamedContent(),
|
|
$this->get(route('share.download.file', [$share, $third]))->streamedContent(),
|
|
];
|
|
$zip = $this->get(route('share.download.all', $share))->streamedContent();
|
|
|
|
expect($files)->toBe(['first file', 'second file', 'third file']);
|
|
expect($zip)->toStartWith("PK\x03\x04");
|
|
expect($share->fresh()->download_count)->toBe(1);
|
|
});
|
|
|
|
test('another recipient cannot open a share whose last download was taken', function (string $route) {
|
|
Storage::fake('shares');
|
|
$share = createShareWithFiles(maxDownloads: 1);
|
|
$this->get(route('share.download.file', [$share, $share->files->first()]));
|
|
$this->flushSession();
|
|
|
|
$response = $this->get(route($route, ['share' => $share, 'shareFile' => $share->files->last()]));
|
|
|
|
$response->assertNotFound();
|
|
})->with([
|
|
'download page' => 'share.download',
|
|
'download all' => 'share.download.all',
|
|
'download one file' => 'share.download.file',
|
|
]);
|
|
|
|
test('the recipient who took the last download can no longer open the share an hour later', function (string $route) {
|
|
Storage::fake('shares');
|
|
$share = createShareWithFiles(maxDownloads: 1);
|
|
$this->get(route('share.download.file', [$share, $share->files->first()]));
|
|
$this->travel(61)->minutes();
|
|
|
|
$response = $this->get(route($route, ['share' => $share, 'shareFile' => $share->files->last()]));
|
|
|
|
$response->assertNotFound();
|
|
})->with([
|
|
'download page' => 'share.download',
|
|
'download all' => 'share.download.all',
|
|
'download one file' => 'share.download.file',
|
|
]);
|
|
|
|
test('each download keeps the recipient\'s window open', function () {
|
|
Storage::fake('shares');
|
|
$share = createShareWithFiles(maxDownloads: 1);
|
|
$this->get(route('share.download.file', [$share, $share->files->first()]));
|
|
$this->travel(50)->minutes();
|
|
$this->get(route('share.download.file', [$share, $share->files->first()]));
|
|
$this->travel(50)->minutes();
|
|
|
|
$response = $this->get(route('share.download.file', [$share, $share->files->last()]));
|
|
|
|
$response->assertOk();
|
|
expect($share->fresh()->download_count)->toBe(1);
|
|
});
|
|
|
|
test('a recipient whose window has ended uses another download when one is left', function () {
|
|
Storage::fake('shares');
|
|
$share = createShareWithFiles(maxDownloads: 2);
|
|
$this->get(route('share.download.file', [$share, $share->files->first()]));
|
|
$this->travel(61)->minutes();
|
|
|
|
$response = $this->get(route('share.download.file', [$share, $share->files->last()]));
|
|
|
|
$response->assertOk();
|
|
expect($share->fresh()->download_count)->toBe(2);
|
|
});
|
|
|
|
test('a recipient is refused the last download once another recipient has taken it', function () {
|
|
Storage::fake('shares');
|
|
$share = createShareWithFiles(maxDownloads: 2);
|
|
$share->update(['download_count' => 1]);
|
|
$this->get(route('share.download.file', [$share, $share->files->first()]));
|
|
$this->flushSession();
|
|
|
|
$response = $this->get(route('share.download.file', [$share, $share->files->first()]));
|
|
|
|
$response->assertNotFound();
|
|
expect($share->fresh()->download_count)->toBe(2);
|
|
});
|
|
|
|
test('a download refused before it starts uses no download', function (?string $password, Closure $file, string $assertion) {
|
|
Storage::fake('shares');
|
|
$share = createShareWithFiles(maxDownloads: 1, password: $password);
|
|
$otherShare = createShareWithFiles();
|
|
|
|
$response = $this->get(route('share.download.file', [$share, $file($share, $otherShare)]));
|
|
|
|
$response->{$assertion}();
|
|
expect($share->fresh()->download_count)->toBe(0);
|
|
})->with([
|
|
'password share without its key' => ['secret-pass', fn (Share $share) => $share->files->first(), 'assertForbidden'],
|
|
'file of another share' => [null, fn (Share $share, Share $otherShare) => $otherShare->files->first(), 'assertNotFound'],
|
|
]);
|
|
|
|
test('a share without a limit counts each recipient once', function () {
|
|
Storage::fake('shares');
|
|
$share = createShareWithFiles();
|
|
$this->get(route('share.download.file', [$share, $share->files->first()]));
|
|
$this->get(route('share.download.file', [$share, $share->files->last()]));
|
|
$this->flushSession();
|
|
|
|
$this->get(route('share.download.file', [$share, $share->files->first()]));
|
|
|
|
expect($share->fresh()->download_count)->toBe(2);
|
|
});
|
|
|
|
test('the download page tells a recipient how many downloads are left', function (int $maxDownloads, string $note) {
|
|
Storage::fake('shares');
|
|
$share = createShareWithFiles(maxDownloads: $maxDownloads);
|
|
|
|
$response = $this->get(route('share.download', $share));
|
|
|
|
$response->assertSee($note);
|
|
})->with([
|
|
'several left' => [3, 'Downloading uses 1 of 3 remaining downloads. You then have 1 hour to download the files.'],
|
|
'one left' => [1, 'Downloading uses the last remaining download. You then have 1 hour to download the files.'],
|
|
]);
|
|
|
|
test('the download page tells the recipient who took the last download how long they have left', function () {
|
|
Storage::fake('shares');
|
|
$this->freezeSecond();
|
|
$share = createShareWithFiles(maxDownloads: 1);
|
|
$this->get(route('share.download.file', [$share, $share->files->first()]));
|
|
$this->travel(2)->minutes();
|
|
|
|
$response = $this->get(route('share.download', $share));
|
|
|
|
$response->assertSee('You can download these files for another 58 minutes.');
|
|
$response->assertDontSee('remaining download');
|
|
});
|
|
|
|
test('the download page says nothing about downloads on a share without a limit', function () {
|
|
Storage::fake('shares');
|
|
$share = createShareWithFiles();
|
|
|
|
$response = $this->get(route('share.download', $share));
|
|
|
|
$response->assertDontSee('to download the files');
|
|
$response->assertDontSee('You can download these files');
|
|
});
|
|
|
|
test('a share whose files are still uploading is not found anywhere a recipient or uploader could open it', function (string $route) {
|
|
Storage::fake('shares');
|
|
$share = Share::factory()->pending()->create();
|
|
$file = ShareFile::factory()->for($share)->uploading()->create();
|
|
|
|
$response = $this->get(route($route, ['share' => $share, 'shareFile' => $file]));
|
|
|
|
$response->assertNotFound();
|
|
})->with([
|
|
'download page' => 'share.download',
|
|
'download all' => 'share.download.all',
|
|
'download one file' => 'share.download.file',
|
|
'share created page' => 'share.created',
|
|
]);
|
|
|
|
test('a password share created before key wrapping still unlocks and downloads', function () {
|
|
Storage::fake('shares');
|
|
$salt = str_repeat('cd', 32);
|
|
$share = Share::factory()->withPassword('old-password')->create(['encryption_salt' => $salt]);
|
|
$file = ShareFile::factory()->for($share)->create(['stored_path' => 'shares/'.$share->token.'/old.enc', 'file_size' => 11]);
|
|
$source = tempnam(sys_get_temp_dir(), 'old');
|
|
file_put_contents($source, 'old content');
|
|
Storage::disk('shares')->makeDirectory($share->token);
|
|
encryptTestFile($source, Storage::disk('shares')->path($share->token.'/old.enc'), bin2hex(hash_pbkdf2('sha256', 'old-password', hex2bin($salt), 100000, 32, true)), 1024);
|
|
unlink($source);
|
|
|
|
Livewire::test(ShareDownload::class, ['share' => $share])
|
|
->set('password', 'old-password')
|
|
->call('verifyPassword')
|
|
->assertSet('authenticated', true);
|
|
|
|
expect($this->get(route('share.download.file', [$share, $file]))->streamedContent())->toBe('old content');
|
|
});
|
|
|
|
test('a text-only share page shows Show text and keeps the plaintext out of the response', function () {
|
|
Storage::fake('shares');
|
|
$share = app(ShareService::class)->createShare([], [], 'the secret note');
|
|
|
|
$response = $this->get(route('share.download', $share));
|
|
|
|
$response->assertOk()
|
|
->assertSeeHtml('data-test="show-text"')
|
|
->assertDontSee('the secret note');
|
|
});
|
|
|
|
test('revealText returns the private text and counts one download', function () {
|
|
Storage::fake('shares');
|
|
$share = app(ShareService::class)->createShare([], [], 'the secret note');
|
|
|
|
Livewire::test(ShareDownload::class, ['share' => $share])
|
|
->call('revealText')
|
|
->assertReturned('the secret note');
|
|
|
|
expect($share->fresh()->download_count)->toBe(1);
|
|
});
|
|
|
|
test('a second reveal in the same window is not counted again', function () {
|
|
Storage::fake('shares');
|
|
$share = app(ShareService::class)->createShare([], [], 'the secret note');
|
|
$component = Livewire::test(ShareDownload::class, ['share' => $share]);
|
|
$component->call('revealText');
|
|
|
|
$component->call('revealText')
|
|
->assertReturned('the secret note');
|
|
|
|
expect($share->fresh()->download_count)->toBe(1);
|
|
});
|
|
|
|
test('another session cannot open a text-only share whose one download was taken', function () {
|
|
Storage::fake('shares');
|
|
$share = app(ShareService::class)->createShare([], ['max_downloads' => 1], 'the secret note');
|
|
Livewire::test(ShareDownload::class, ['share' => $share])->call('revealText');
|
|
$this->flushSession();
|
|
|
|
$response = $this->get(route('share.download', $share));
|
|
|
|
$response->assertNotFound();
|
|
});
|
|
|
|
test('a password share\'s revealText returns null before unlocking and counts nothing', function () {
|
|
Storage::fake('shares');
|
|
$share = app(ShareService::class)->createShare([], ['password' => 'let-me-in'], 'the secret note');
|
|
|
|
Livewire::test(ShareDownload::class, ['share' => $share])
|
|
->call('revealText')
|
|
->assertReturned(null);
|
|
|
|
expect($share->fresh()->download_count)->toBe(0);
|
|
});
|
|
|
|
test('share.download.file for the text row returns 404', function () {
|
|
Storage::fake('shares');
|
|
$share = app(ShareService::class)->createShare(
|
|
[['file' => UploadedFile::fake()->createWithContent('notes.txt', 'file content'), 'relativePath' => null]],
|
|
[],
|
|
'the secret note',
|
|
);
|
|
|
|
$response = $this->get(route('share.download.file', [$share, $share->textFile]));
|
|
|
|
$response->assertNotFound();
|
|
});
|
|
|
|
test('the zip of a mixed share has only the files, not the private text', function () {
|
|
Storage::fake('shares');
|
|
$share = app(ShareService::class)->createShare(
|
|
[['file' => UploadedFile::fake()->createWithContent('notes.txt', 'file content'), 'relativePath' => null]],
|
|
[],
|
|
'the secret note',
|
|
);
|
|
$zipPath = tempnam(sys_get_temp_dir(), 'zip');
|
|
|
|
$response = $this->get(route('share.download.all', $share));
|
|
file_put_contents($zipPath, $content = $response->streamedContent());
|
|
|
|
$response->assertHeader('Content-Length', strlen($content));
|
|
|
|
$zip = new ZipArchive;
|
|
expect($zip->open($zipPath))->toBeTrue();
|
|
expect($zip->numFiles)->toBe(1);
|
|
expect($zip->getFromName('notes.txt'))->toBe('file content');
|
|
expect($zip->locateName('text.txt'))->toBeFalse();
|
|
$zip->close();
|
|
unlink($zipPath);
|
|
});
|
|
|
|
test('share.download.all on a text-only share returns 404', function () {
|
|
Storage::fake('shares');
|
|
$share = app(ShareService::class)->createShare([], [], 'the secret note');
|
|
|
|
$response = $this->get(route('share.download.all', $share));
|
|
|
|
$response->assertNotFound();
|
|
});
|
|
|
|
/**
|
|
* Helper to create a share with an actual encrypted file.
|
|
*/
|
|
function createShareWithFile(?string $password = null, string $content = 'test content'): Share
|
|
{
|
|
return app(ShareService::class)->createShare([
|
|
['file' => UploadedFile::fake()->createWithContent('testfile.txt', $content), 'relativePath' => null],
|
|
], [
|
|
'password' => $password,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Helper to create a share with three encrypted files: "first file", "second file" and "third file".
|
|
*/
|
|
function createShareWithFiles(?int $maxDownloads = null, ?string $password = null): Share
|
|
{
|
|
return app(ShareService::class)->createShare([
|
|
['file' => UploadedFile::fake()->createWithContent('first.txt', 'first file'), 'relativePath' => null],
|
|
['file' => UploadedFile::fake()->createWithContent('second.txt', 'second file'), 'relativePath' => null],
|
|
['file' => UploadedFile::fake()->createWithContent('third.txt', 'third file'), 'relativePath' => null],
|
|
], [
|
|
'password' => $password,
|
|
'max_downloads' => $maxDownloads,
|
|
]);
|
|
}
|