With a download limit of 1, downloading one file of a share with several files deleted the share and the files not yet downloaded. ShareService::recordDownload() ran at the end of every download request, one file or the ZIP alike, and deleted the share as soon as download_count reached max_downloads. It has worked that way since the first commit. - One recipient's visit is one download. The first file or ZIP a session downloads is counted when it starts, in one conditional UPDATE that also checks the limit, so two recipients starting at once can't both take the last download. The session remembers the time, and for ShareService::DOWNLOAD_WINDOW_MINUTES (60) it may start more downloads of the share without counting them, even once the limit is reached. The claim happens in the controller before streaming, because the session is saved before the body is sent, and after the share key is resolved, so a request without the key uses nothing. - A share at its limit is closed to everyone else at once. The hourly cleanup deletes it 24 hours after shares.last_downloaded_at (new column), since a ZIP opens each file only when it reaches it and a large download can outlast the hour. - The download page of a limited share says how many downloads are left, switches to "You have 1 hour" on the first press (Alpine, as a download link does not render the page again), and shows the time left on the next visit. - The admin dashboard shows "2 of 3 downloads", marks shares at their limit "Download limit reached" and leaves them out of Active Shares. - Tests: the regression (3 files, limit 1: every file and the ZIP download, counted once), another recipient, the end of the hour, the last download going to one of two recipients, requests refused before streaming, unlimited shares, the page notes in PHP and in Chromium, the dashboard, and the cleanup at 23 and 25 hours. The tests of recordDownload() and of the instant deletion are gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
320 lines
12 KiB
PHP
320 lines
12 KiB
PHP
<?php
|
|
|
|
use App\Livewire\ShareDownload;
|
|
use App\Models\Share;
|
|
use App\Models\ShareFile;
|
|
use App\Services\FileEncryptionService;
|
|
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 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');
|
|
|
|
file_put_contents($zipPath, $this->get(route('share.download.all', $share))->streamedContent());
|
|
|
|
$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('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('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);
|
|
app(FileEncryptionService::class)->encryptFile($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');
|
|
});
|
|
|
|
/**
|
|
* 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,
|
|
]);
|
|
}
|