Encrypt uploads in the browser and send them in chunks
A 6 GB upload kept a customer waiting long after its progress bar
reached 100%. The server wrote every upload three times: PHP's
temporary file, Livewire's copy of it ("Processing files...") and the
encrypted file ("Create Share Link"), each a full rewrite of a slow
disk. The unencrypted copy also stayed behind in livewire-tmp.
Now the uploader's browser encrypts each file in 16 MB chunks with
WebCrypto and PUTs them one at a time; the server checks each chunk in
memory and writes it once, already encrypted. Creating the share only
wraps its key and saves the options. A 200 MB upload through the
Docker image took 2.8 s, and its download matched byte for byte.
- SEALCHK2: a 19-byte header (chunk size, 7-byte nonce prefix), then
ciphertext and tag per chunk. Each nonce holds the chunk index and a
last-chunk flag (the STREAM construction), so cut or reordered files
fail to decrypt. SEALCHK1 and the single-block format still read.
- Envelope encryption: one random key per share. With a password it is
wrapped with Argon2id (sodium, libsodium's interactive limits) in
shares.wrapped_key, which names its parameters. Password shares from
before keep their PBKDF2-derived key.
- The upload page registers each selection with FileUploader into a
pending share of its own, lists the files with their progress, retries
a failed chunk after 1-16 s, then offers Retry; Remove and Cancel
abort. UploadChunkController only accepts chunks from the session that
started the share: a repeat is acknowledged, a skip gets 409 with the
count stored. Chunks go out as Blobs, which Chromium sends about eight
times faster than ArrayBuffers.
- Uploads need a secure context: over plain HTTP the page says HTTPS is
needed and takes no files. The Docker image gains AUTO_HTTPS, which
serves Let's Encrypt on 443 for SERVER_NAME and redirects 80; without
it the container stays on HTTP 80 behind a proxy. docker/Caddyfile was
never loaded and is gone; docker/healthcheck.sh covers both modes.
- "Download all" streams the ZIP with maennchen/zipstream-php (STORE,
ZIP64) instead of decrypting whole files into memory and writing the
archive unencrypted to /tmp.
- Pending shares count towards the quota, stay out of the admin
dashboard and 404 everywhere else. shares:cleanup deletes uploads idle
for 4 hours and Livewire temporary files older than that.
- PHP's upload limits no longer cap the admin's max file size and
default to 64M; LIVEWIRE_MAX_UPLOAD_TIME is gone and
UPLOAD_CHUNK_SIZE_MB is new.
- Tests cover the format, key wrapping, registration limits, the chunk
endpoint's answers, completing a share, the streamed ZIP, cleanup,
and in Chromium a real chunked upload and the HTTPS warning; the
selected-files overflow test runs again. README, website, CHANGELOG
and .ai/rules follow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
504971ad7f
commit
40e35bab0e
@@ -3,6 +3,7 @@
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Models\User;
|
||||
use App\Services\FileEncryptionService;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
@@ -28,8 +29,28 @@ test('files dragged over the drop zone turn its shape into a burst', function ()
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
// Pest's in-process server does not store a multipart upload, so the upload itself is covered by
|
||||
// FileUploadTest; this picks up where it ends, on the page the upload leads to.
|
||||
test('a chosen file is encrypted in the browser, sent in chunks and shared with its exact content', function () {
|
||||
// Pest's in-process server takes request bodies up to 128 KB: 64 KB chunks send this file in three.
|
||||
config(['uploads.chunk_size' => 64 * 1024]);
|
||||
$content = random_bytes(150 * 1024);
|
||||
$path = sys_get_temp_dir().'/sealshare-browser-upload-'.uniqid().'.bin';
|
||||
file_put_contents($path, $content);
|
||||
|
||||
$page = ready(visit('/upload'));
|
||||
$page->attach('[data-test="file-input"]', $path)
|
||||
->waitForText('Uploaded')
|
||||
->click('[data-test="create-share"]')
|
||||
->waitForText('Share Created!')
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
$file = Share::query()->sole()->files->sole();
|
||||
expect($file->uploaded_chunks)->toBe(3);
|
||||
$stored = app(FileEncryptionService::class)->decryptedChunks(app(ShareService::class)->storedFilePath($file), $file->share->encryption_key);
|
||||
expect(implode('', iterator_to_array($stored, false)))->toBe($content);
|
||||
|
||||
unlink($path);
|
||||
});
|
||||
|
||||
test('a new share\'s link can be copied from the page the upload leads to', function () {
|
||||
$share = app(ShareService::class)->createShare(
|
||||
[['file' => UploadedFile::fake()->create('contract.pdf', 80), 'relativePath' => null]],
|
||||
|
||||
@@ -45,12 +45,37 @@ test('tab reaches Browse Files with its focus ring, and Enter or Space opens the
|
||||
});
|
||||
|
||||
test('the selected files list avoids horizontal overflow once files are chosen', function () {
|
||||
// Pest's in-process browser server never parses the multipart body a real file selection sends
|
||||
// (vendor/pestphp/pest-plugin-browser/src/Drivers/LaravelHttpServer.php:257, `[], // @TODO
|
||||
// files...`) — Livewire's temporary-upload request has nowhere to land, so a selected file can
|
||||
// never reach this list to be measured. tests/Feature/FileUploadTest.php covers the list's data
|
||||
// through Livewire::test(); driving a real selection through the browser is impractical here.
|
||||
})->skip('the in-process browser server drops multipart uploads (LaravelHttpServer.php:257): a real file selection cannot reach the list');
|
||||
$path = sys_get_temp_dir().'/a-genuinely-quite-long-holiday-photos-archive-from-portugal-'.uniqid().'.zip';
|
||||
file_put_contents($path, 'archive');
|
||||
|
||||
$page = ready(visit('/upload')->resize(393, 852));
|
||||
$page->attach('[data-test="file-input"]', $path)
|
||||
->waitForText('Uploaded');
|
||||
|
||||
$rows = $page->script("(() => {
|
||||
const rows = [...document.querySelectorAll('[data-test=selected-file]')];
|
||||
return { count: rows.length, allFit: rows.every((row) => row.getBoundingClientRect().right <= window.innerWidth + 0.5) };
|
||||
})()");
|
||||
|
||||
expect($rows['count'])->toBe(1);
|
||||
expect($rows['allFit'])->toBeTrue();
|
||||
$page->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
|
||||
->assertNoJavaScriptErrors();
|
||||
|
||||
unlink($path);
|
||||
});
|
||||
|
||||
test('without a secure context the upload page says HTTPS is needed and takes no files', function () {
|
||||
$page = ready(visit('/upload'));
|
||||
|
||||
$page->script("window.eval(\"Alpine.\$data(document.querySelector('[data-test=drop-zone]')).secure = false\")");
|
||||
|
||||
$page->assertScript("getComputedStyle(document.querySelector('[data-test=insecure-context]')).display !== 'none'")
|
||||
->assertSee('Uploads need a secure connection (HTTPS).')
|
||||
->assertScript("document.querySelector('[data-test=file-input]').disabled === true")
|
||||
->assertScript("document.querySelector('[data-test=drop-zone]').getAttribute('aria-disabled') === 'true'")
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
test('the drop zone hides its burst again once the drag leaves', function () {
|
||||
$page = ready(visit('/upload'));
|
||||
|
||||
@@ -96,3 +96,20 @@ test('without shares the dashboard shows an empty state instead of the table', f
|
||||
->assertSee('No shares yet')
|
||||
->assertDontSee('<table', false);
|
||||
});
|
||||
|
||||
test('shares whose files are still uploading are neither listed nor counted, but their bytes count as used space', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
$completed = Share::factory()->create(['token' => 'completedshare01', 'total_size' => 1000]);
|
||||
ShareFile::factory()->for($completed)->create();
|
||||
$pending = Share::factory()->pending()->create(['token' => 'pendingshare0001', 'total_size' => 500]);
|
||||
ShareFile::factory()->for($pending)->uploading()->create();
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminDashboard::class)
|
||||
->assertSee('completedshare01')
|
||||
->assertDontSee('pendingshare0001')
|
||||
->assertViewHas('totalShares', 1)
|
||||
->assertViewHas('activeShares', 1)
|
||||
->assertViewHas('totalFiles', 1)
|
||||
->assertViewHas('usedSpace', 1500);
|
||||
});
|
||||
|
||||
@@ -33,11 +33,9 @@ test('admin can access settings page', function () {
|
||||
test('admin can save settings', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
|
||||
$phpMaxMb = AdminSettings::phpMaxUploadMb();
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminSettings::class)
|
||||
->set('maxFileSize', min(200, $phpMaxMb))
|
||||
->set('maxFileSize', 200)
|
||||
->set('maxStorageQuota', 50)
|
||||
->set('maxFilesPerShare', 100)
|
||||
->set('maxSizePerShare', 5)
|
||||
@@ -46,7 +44,7 @@ test('admin can save settings', function () {
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('toast', type: 'success', title: 'Settings saved successfully.');
|
||||
|
||||
expect(Setting::get('max_file_size'))->toBe((string) (min(200, $phpMaxMb) * 1024 * 1024));
|
||||
expect(Setting::get('max_file_size'))->toBe((string) (200 * 1024 * 1024));
|
||||
expect(Setting::get('max_storage_quota'))->toBe((string) (50 * 1024 * 1024 * 1024));
|
||||
expect(Setting::get('max_files_per_share'))->toBe('100');
|
||||
expect(Setting::get('max_size_per_share'))->toBe((string) (5 * 1024 * 1024 * 1024));
|
||||
@@ -55,11 +53,9 @@ test('admin can save settings', function () {
|
||||
|
||||
test('admin can set system password', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
$phpMaxMb = AdminSettings::phpMaxUploadMb();
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminSettings::class)
|
||||
->set('maxFileSize', $phpMaxMb)
|
||||
->set('maxFileSize', 100)
|
||||
->set('systemPassword', 'new-system-password')
|
||||
->call('saveSettings')
|
||||
->assertHasNoErrors();
|
||||
@@ -87,8 +83,7 @@ test('admin can clear system password', function () {
|
||||
|
||||
test('settings page loads existing values', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
$phpMaxMb = AdminSettings::phpMaxUploadMb();
|
||||
$testSize = min(40, $phpMaxMb);
|
||||
$testSize = 40;
|
||||
|
||||
Setting::set('max_file_size', $testSize * 1024 * 1024);
|
||||
Setting::set('max_files_per_share', 75);
|
||||
@@ -261,3 +256,16 @@ test('switching the password generator off hides its options', function () {
|
||||
->assertDontSeeHtml('wire:model.live="passwordGeneratorType"')
|
||||
->assertDontSeeHtml('data-test="password-example"');
|
||||
});
|
||||
|
||||
test('a max file size far above PHP\'s upload limit loads and saves unchanged', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
Setting::set('max_file_size', 15000 * 1024 * 1024);
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminSettings::class)
|
||||
->assertSet('maxFileSize', 15000)
|
||||
->call('saveSettings')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Setting::get('max_file_size'))->toBe((string) (15000 * 1024 * 1024));
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\Share;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
|
||||
|
||||
test('cleanup removes expired shares', function () {
|
||||
Storage::fake('shares');
|
||||
@@ -58,3 +59,35 @@ test('cleanup removes both expired and download-limited shares', function () {
|
||||
expect(Share::query()->find($limitReached->id))->toBeNull();
|
||||
expect(Share::query()->find($active->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('cleanup removes uploads no chunk reached for 4 hours, and keeps recent ones and completed shares', function () {
|
||||
Storage::fake('shares');
|
||||
$this->freezeTime();
|
||||
$abandoned = Share::factory()->pending()->create(['updated_at' => now()->subHours(4)->subMinute()]);
|
||||
Storage::disk('shares')->put($abandoned->token.'/file.enc', 'encrypted');
|
||||
$recent = Share::factory()->pending()->create(['updated_at' => now()->subHours(3)]);
|
||||
$completed = Share::factory()->create(['updated_at' => now()->subDays(3)]);
|
||||
|
||||
$this->artisan('shares:cleanup')
|
||||
->expectsOutputToContain('Cleaned up 1 abandoned upload(s)')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertModelMissing($abandoned);
|
||||
$this->assertModelExists($recent);
|
||||
$this->assertModelExists($completed);
|
||||
expect(Storage::disk('shares')->directories())->not->toContain($abandoned->token);
|
||||
});
|
||||
|
||||
test('cleanup removes temporary upload files older than 4 hours and keeps newer ones', function () {
|
||||
$storage = FileUploadConfiguration::storage();
|
||||
$storage->put(FileUploadConfiguration::path('old.pdf'), 'unencrypted leftover');
|
||||
$storage->put(FileUploadConfiguration::path('new.png'), 'a logo being chosen');
|
||||
touch($storage->path(FileUploadConfiguration::path('old.pdf')), now()->subHours(5)->getTimestamp());
|
||||
|
||||
$this->artisan('shares:cleanup')
|
||||
->expectsOutputToContain('Cleaned up 1 temporary upload file(s)')
|
||||
->assertExitCode(0);
|
||||
|
||||
expect($storage->exists(FileUploadConfiguration::path('old.pdf')))->toBeFalse();
|
||||
expect($storage->exists(FileUploadConfiguration::path('new.png')))->toBeTrue();
|
||||
});
|
||||
|
||||
@@ -4,13 +4,41 @@ use App\Livewire\FileUploader;
|
||||
use App\Livewire\SystemPasswordPrompt;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Upload files through the page as the browser does: register them in one batch, then store each
|
||||
* one's encrypted content (the chunk endpoint itself is covered by UploadChunkTest).
|
||||
*
|
||||
* @param array<string, string> $files name => content
|
||||
* @return array<int, array<string, mixed>|null> what the page handed the browser
|
||||
*/
|
||||
function uploadThroughPage(Testable $component, array $files): array
|
||||
{
|
||||
$targets = [];
|
||||
|
||||
$component->call('registerFiles', collect($files)->map(fn (string $content, string $name): array => ['name' => $name, 'size' => strlen($content), 'path' => null])->values()->all())
|
||||
->assertReturned(function (array $returned) use (&$targets): bool {
|
||||
$targets = $returned;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
foreach (array_values($files) as $position => $content) {
|
||||
if ($targets[$position] !== null) {
|
||||
$file = ShareFile::query()->findOrFail($targets[$position]['id']);
|
||||
app(ShareService::class)->storeChunk($file, 0, encryptedChunk($file, $content, 0, true));
|
||||
}
|
||||
}
|
||||
|
||||
return $targets;
|
||||
}
|
||||
|
||||
test('upload page can be rendered', function () {
|
||||
$response = $this->get(route('upload'));
|
||||
|
||||
@@ -36,28 +64,100 @@ test('upload page accessible after system password verified', function () {
|
||||
|
||||
test('file upload creates share', function () {
|
||||
Storage::fake('shares');
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
uploadThroughPage($component, ['document.pdf' => 'the document']);
|
||||
|
||||
$file = UploadedFile::fake()->create('document.pdf', 1024);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
->call('createShare')
|
||||
$component->call('createShare')
|
||||
->assertRedirectContains('/share/');
|
||||
|
||||
expect(Share::query()->count())->toBe(1);
|
||||
$share = Share::query()->sole();
|
||||
expect($share->isCompleted())->toBeTrue();
|
||||
expect($share->files->pluck('original_name')->all())->toBe(['document.pdf']);
|
||||
expect(session('pending_shares'))->not->toContain($share->token);
|
||||
});
|
||||
|
||||
$share = Share::query()->first();
|
||||
expect($share->files)->toHaveCount(1);
|
||||
expect($share->files->first()->original_name)->toBe('document.pdf');
|
||||
test('registering files hands the browser each file\'s chunk URL, the share key and the file\'s nonce prefix', function () {
|
||||
Storage::fake('shares');
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
|
||||
$targets = uploadThroughPage($component, ['a.txt' => 'abc']);
|
||||
|
||||
$file = ShareFile::query()->sole();
|
||||
expect($targets[0])->toMatchArray([
|
||||
'id' => $file->id,
|
||||
'url' => url('upload/files/'.$file->id.'/chunks'),
|
||||
'key' => $file->share->encryption_key,
|
||||
'chunkSize' => 4,
|
||||
'chunkCount' => 1,
|
||||
]);
|
||||
expect($targets[0]['noncePrefix'])->toBe(bin2hex(substr(file_get_contents(app(ShareService::class)->storedFilePath($file)), 12, 7)));
|
||||
expect(session('pending_shares'))->toBe([$file->share->token]);
|
||||
$component->assertSet('pendingToken', $file->share->token)
|
||||
->assertSeeHtml('data-test="selected-file"');
|
||||
});
|
||||
|
||||
test('each upload page gets its own pending share', function () {
|
||||
Storage::fake('shares');
|
||||
$first = Livewire::test(FileUploader::class);
|
||||
$second = Livewire::test(FileUploader::class);
|
||||
|
||||
uploadThroughPage($first, ['one.txt' => 'one']);
|
||||
uploadThroughPage($second, ['two.txt' => 'two']);
|
||||
|
||||
expect($first->get('pendingToken'))->not->toBe($second->get('pendingToken'));
|
||||
expect(Share::query()->pluck('total_size')->all())->toBe([3, 3]);
|
||||
});
|
||||
|
||||
test('a file an admin limit refuses gets no target and shows why, while the rest of the batch is registered', function () {
|
||||
Storage::fake('shares');
|
||||
Setting::set('max_file_size', 1024 * 1024);
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
|
||||
$component->call('registerFiles', [
|
||||
['name' => 'small.txt', 'size' => 3, 'path' => null],
|
||||
['name' => 'large.txt', 'size' => 2 * 1024 * 1024, 'path' => null],
|
||||
]);
|
||||
|
||||
$component->assertReturned(fn (array $targets): bool => $targets[0] !== null && $targets[1] === null);
|
||||
expect($component->errors()->first('files'))->toBe('"large.txt" is too large (2 MB). Maximum file size is 1 MB.');
|
||||
expect(ShareFile::query()->pluck('original_name')->all())->toBe(['small.txt']);
|
||||
});
|
||||
|
||||
test('removing files takes them out of the pending share', function () {
|
||||
Storage::fake('shares');
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
$targets = uploadThroughPage($component, ['keep.txt' => 'keep', 'remove.txt' => 'remove']);
|
||||
|
||||
$component->call('removeFiles', [$targets[1]['id']]);
|
||||
|
||||
expect(ShareFile::query()->pluck('original_name')->all())->toBe(['keep.txt']);
|
||||
expect(Share::query()->sole()->total_size)->toBe(4);
|
||||
});
|
||||
|
||||
test('a share cannot be created while a file is still uploading', function () {
|
||||
Storage::fake('shares');
|
||||
$component = Livewire::test(FileUploader::class)
|
||||
->call('registerFiles', [['name' => 'unfinished.txt', 'size' => 10, 'path' => null]]);
|
||||
|
||||
$component->call('createShare');
|
||||
|
||||
expect($component->errors()->first('files'))->toBe('Wait until every file has finished uploading, or remove the ones that failed.');
|
||||
expect(Share::query()->sole()->isCompleted())->toBeFalse();
|
||||
});
|
||||
|
||||
test('the page offers a warning for browsers without a secure context', function () {
|
||||
Livewire::test(FileUploader::class)
|
||||
->assertSeeHtml('data-test="insecure-context"')
|
||||
->assertSee('Uploads need a secure connection (HTTPS).');
|
||||
});
|
||||
|
||||
test('file upload with password creates password-protected share', function () {
|
||||
Storage::fake('shares');
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
uploadThroughPage($component, ['secret.txt' => 'secret']);
|
||||
|
||||
$file = UploadedFile::fake()->create('secret.txt', 512);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
$component
|
||||
->set('usePassword', true)
|
||||
->set('password', 'my-password')
|
||||
->call('createShare')
|
||||
@@ -79,9 +179,9 @@ test('switching password protection on leaves the field empty and offers a Gener
|
||||
test('a generated password protects the share and is flashed, encrypted, for the page the upload leads to', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$component = Livewire::test(FileUploader::class)
|
||||
->set('files', [UploadedFile::fake()->create('secret.txt', 512)])
|
||||
->set('usePassword', true)
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
uploadThroughPage($component, ['secret.txt' => 'secret']);
|
||||
$component->set('usePassword', true)
|
||||
->call('generatePassword');
|
||||
$password = $component->get('password');
|
||||
$component->call('createShare');
|
||||
@@ -96,9 +196,10 @@ test('a generated password protects the share and is flashed, encrypted, for the
|
||||
test('a share without a password flashes no password', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [UploadedFile::fake()->create('document.pdf', 100)])
|
||||
->call('createShare')
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
uploadThroughPage($component, ['document.pdf' => 'the document']);
|
||||
|
||||
$component->call('createShare')
|
||||
->assertRedirectContains('/share/');
|
||||
|
||||
expect(session()->has('share_password'))->toBeFalse();
|
||||
@@ -136,10 +237,10 @@ test('a generator switched off offers no Generate button and generates nothing',
|
||||
test('file upload with expiration sets expires_at', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$file = UploadedFile::fake()->create('file.txt', 256);
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
uploadThroughPage($component, ['file.txt' => 'content']);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
$component
|
||||
->set('expiration', '24h')
|
||||
->call('createShare')
|
||||
->assertRedirectContains('/share/');
|
||||
@@ -151,10 +252,10 @@ test('file upload with expiration sets expires_at', function () {
|
||||
test('file upload with max downloads sets limit', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$file = UploadedFile::fake()->create('file.txt', 256);
|
||||
$component = Livewire::test(FileUploader::class);
|
||||
uploadThroughPage($component, ['file.txt' => 'content']);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
$component
|
||||
->set('maxDownloads', 5)
|
||||
->call('createShare')
|
||||
->assertRedirectContains('/share/');
|
||||
@@ -163,69 +264,26 @@ test('file upload with max downloads sets limit', function () {
|
||||
expect($share->max_downloads)->toBe(5);
|
||||
});
|
||||
|
||||
test('every upload batch dispatches files-processed to clear the uploading state', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [UploadedFile::fake()->create('first.txt', 64)])
|
||||
->assertDispatched('files-processed')
|
||||
->set('files', [UploadedFile::fake()->create('second.txt', 64)])
|
||||
->assertDispatched('files-processed');
|
||||
});
|
||||
|
||||
test('files added in multiple batches end up in the same share', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [UploadedFile::fake()->create('first.txt', 64)])
|
||||
->set('files', [UploadedFile::fake()->create('second.txt', 64)])
|
||||
->call('createShare')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirectContains('/share/');
|
||||
|
||||
$share = Share::query()->first();
|
||||
|
||||
expect($share->files->pluck('original_name')->all())->toBe(['first.txt', 'second.txt']);
|
||||
});
|
||||
|
||||
test('files larger than 4 GB can be shared when within the admin file size limit', function () {
|
||||
test('a file of 6 GB is accepted when the admin limits allow it', function () {
|
||||
Storage::fake('shares');
|
||||
Setting::set('max_file_size', 15000 * 1024 * 1024);
|
||||
Setting::set('max_size_per_share', 20 * 1024 * 1024 * 1024);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [UploadedFile::fake()->create('backup.dump', 6 * 1024 * 1024)])
|
||||
->assertHasNoErrors('files')
|
||||
->call('createShare')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirectContains('/share/');
|
||||
|
||||
expect(Share::query()->first()->total_size)->toBe(6 * 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('a rejected upload logs the real reason instead of blaming the file size limit', function () {
|
||||
Log::spy();
|
||||
Setting::set('max_file_size', 15000 * 1024 * 1024);
|
||||
|
||||
$errors = ['files.0' => ['The files.0 failed to upload.']];
|
||||
Setting::set('max_storage_quota', 50 * 1024 * 1024 * 1024);
|
||||
|
||||
$component = Livewire::test(FileUploader::class)
|
||||
->call('_uploadErrored', 'files', json_encode(['errors' => $errors]), true)
|
||||
->assertDispatched('upload:errored');
|
||||
->call('registerFiles', [['name' => 'backup.dump', 'size' => 6 * 1024 * 1024 * 1024, 'path' => null]]);
|
||||
|
||||
expect($component->errors()->first('files'))
|
||||
->toBe('Upload failed: the server could not accept the file. Please try again or contact the administrator.');
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(fn (string $message, array $context): bool => $context['errors'] === $errors)
|
||||
->once();
|
||||
$component->assertHasNoErrors('files');
|
||||
expect(Share::query()->sole()->total_size)->toBe(6 * 1024 * 1024 * 1024);
|
||||
expect(ShareFile::query()->sole()->file_size)->toBe(6 * 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('file upload requires at least one file', function () {
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [])
|
||||
->call('createShare')
|
||||
->assertHasErrors(['files']);
|
||||
$component = Livewire::test(FileUploader::class)
|
||||
->call('createShare');
|
||||
|
||||
expect($component->errors()->first('files'))->toBe('Please select at least one file to upload.');
|
||||
expect(Share::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('file upload blocks when storage is full', function () {
|
||||
@@ -233,12 +291,11 @@ test('file upload blocks when storage is full', function () {
|
||||
Setting::set('max_storage_quota', 100);
|
||||
Share::factory()->create(['total_size' => 100]);
|
||||
|
||||
$file = UploadedFile::fake()->create('file.txt', 1);
|
||||
$component = Livewire::test(FileUploader::class)
|
||||
->call('registerFiles', [['name' => 'file.txt', 'size' => 1, 'path' => null]]);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
->call('createShare')
|
||||
->assertHasErrors(['files']);
|
||||
expect($component->errors()->first('files'))->toBe('Storage is full. Please contact the administrator.');
|
||||
expect(ShareFile::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('system password prompt verifies correct password', function () {
|
||||
|
||||
@@ -4,8 +4,8 @@ use App\Livewire\Admin\AdminSettings;
|
||||
use App\Livewire\FileUploader;
|
||||
use App\Livewire\ShareDownload;
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Models\User;
|
||||
use App\Services\FileEncryptionService;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
@@ -94,23 +94,17 @@ test('rate limiter clears after successful password verification', function () {
|
||||
test('share password must be at least 8 characters', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
->set('usePassword', true)
|
||||
->set('password', 'short')
|
||||
->call('createShare')
|
||||
->assertHasErrors(['password']);
|
||||
->assertHasErrors(['password' => 'min']);
|
||||
});
|
||||
|
||||
test('share password of 8 characters is accepted', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
->set('usePassword', true)
|
||||
->set('password', 'longenough')
|
||||
->call('createShare')
|
||||
@@ -151,42 +145,17 @@ test('setup wizard createAdmin is blocked when admin already exists', function (
|
||||
test('content disposition handles special characters in filename', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$service = app(ShareService::class);
|
||||
$encryptionService = app(FileEncryptionService::class);
|
||||
|
||||
$file = UploadedFile::fake()->create('normal.txt', 100);
|
||||
|
||||
$share = $service->createShare([
|
||||
['file' => $file, 'relativePath' => null],
|
||||
$share = app(ShareService::class)->createShare([
|
||||
['file' => UploadedFile::fake()->createWithContent('normal.txt', 'test content'), 'relativePath' => null],
|
||||
]);
|
||||
|
||||
$share->load('files');
|
||||
$shareFile = $share->files->first();
|
||||
$shareFile->update(['original_name' => 'file"with"quotes.txt']);
|
||||
|
||||
$shareFile->original_name = 'file"with"quotes.txt';
|
||||
$shareFile->save();
|
||||
$response = $this->get(route('share.download.file', [$share, $shareFile]));
|
||||
|
||||
$encryptedDir = Storage::disk('shares')->path($share->token);
|
||||
if (! is_dir($encryptedDir)) {
|
||||
mkdir($encryptedDir, 0755, true);
|
||||
}
|
||||
$encryptedPath = $encryptedDir.'/'.basename($shareFile->stored_path);
|
||||
$tempSource = tempnam(sys_get_temp_dir(), 'test');
|
||||
file_put_contents($tempSource, 'test content');
|
||||
$encryptionService->encryptFile($tempSource, $encryptedPath, $share->encryption_key);
|
||||
unlink($tempSource);
|
||||
|
||||
$response = $encryptionService->decryptFileStream(
|
||||
$encryptedPath,
|
||||
$share->encryption_key,
|
||||
'file"with"quotes.txt',
|
||||
'text/plain',
|
||||
12,
|
||||
);
|
||||
|
||||
$contentDisposition = $response->headers->get('Content-Disposition');
|
||||
expect($contentDisposition)->not->toContain('file"with"quotes.txt');
|
||||
expect($contentDisposition)->toContain('attachment');
|
||||
expect($response->headers->get('Content-Disposition'))
|
||||
->toContain('attachment')
|
||||
->not->toContain('file"with"quotes.txt');
|
||||
});
|
||||
|
||||
// --- SVG upload rejected ---
|
||||
@@ -194,11 +163,9 @@ test('content disposition handles special characters in filename', function () {
|
||||
test('svg upload is rejected for site logo', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
|
||||
$phpMaxMb = AdminSettings::phpMaxUploadMb();
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminSettings::class)
|
||||
->set('maxFileSize', $phpMaxMb)
|
||||
->set('maxFileSize', 100)
|
||||
->set('siteLogo', UploadedFile::fake()->create('logo.svg', 100, 'image/svg+xml'))
|
||||
->call('saveSettings')
|
||||
->assertHasErrors(['siteLogo']);
|
||||
@@ -206,52 +173,26 @@ test('svg upload is rejected for site logo', function () {
|
||||
|
||||
// --- Relative path validation (Zip Slip prevention) ---
|
||||
|
||||
test('relative paths with directory traversal are sanitized', function () {
|
||||
test('relative paths from a dropped folder that could reach outside the share are dropped', function (string $relativePath) {
|
||||
Storage::fake('shares');
|
||||
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
->set('relativePaths', ['../../etc/passwd'])
|
||||
->call('createShare')
|
||||
->assertRedirectContains('/share/');
|
||||
->call('registerFiles', [['name' => 'file.txt', 'size' => 100, 'path' => $relativePath]]);
|
||||
|
||||
$share = Share::query()->first();
|
||||
$shareFile = $share->files->first();
|
||||
expect($shareFile->relative_path)->toBeNull();
|
||||
});
|
||||
|
||||
test('relative paths with absolute paths are sanitized', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
->set('relativePaths', ['/etc/passwd'])
|
||||
->call('createShare')
|
||||
->assertRedirectContains('/share/');
|
||||
|
||||
$share = Share::query()->first();
|
||||
$shareFile = $share->files->first();
|
||||
expect($shareFile->relative_path)->toBeNull();
|
||||
});
|
||||
expect(ShareFile::query()->sole()->relative_path)->toBeNull();
|
||||
})->with([
|
||||
'directory traversal' => '../../etc/passwd',
|
||||
'absolute path' => '/etc/passwd',
|
||||
'windows traversal' => '..\\..\\windows\\system.ini',
|
||||
]);
|
||||
|
||||
test('valid relative paths are preserved', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
|
||||
Livewire::test(FileUploader::class)
|
||||
->set('files', [$file])
|
||||
->set('relativePaths', ['folder/subfolder/file.txt'])
|
||||
->call('createShare')
|
||||
->assertRedirectContains('/share/');
|
||||
->call('registerFiles', [['name' => 'file.txt', 'size' => 100, 'path' => 'folder/subfolder/file.txt']]);
|
||||
|
||||
$share = Share::query()->first();
|
||||
$shareFile = $share->files->first();
|
||||
expect($shareFile->relative_path)->toBe('folder/subfolder/file.txt');
|
||||
expect(ShareFile::query()->sole()->relative_path)->toBe('folder/subfolder/file.txt');
|
||||
});
|
||||
|
||||
// --- Security headers ---
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Livewire\ShareDownload;
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\FileEncryptionService;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
@@ -81,102 +82,79 @@ test('non-password share shows files directly', function () {
|
||||
|
||||
test('download counter increments on zip download', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$share = createShareWithFile();
|
||||
$share->load('files');
|
||||
|
||||
$encryptionService = app(FileEncryptionService::class);
|
||||
$key = $share->encryption_key;
|
||||
$content = 'test content';
|
||||
|
||||
foreach ($share->files as $file) {
|
||||
$dir = Storage::disk('shares')->path($share->token);
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
$encryptedPath = $dir.'/'.basename($file->stored_path);
|
||||
$tempSource = tempnam(sys_get_temp_dir(), 'test');
|
||||
file_put_contents($tempSource, $content);
|
||||
$encryptionService->encryptFile($tempSource, $encryptedPath, $key);
|
||||
unlink($tempSource);
|
||||
$file->update(['file_size' => strlen($content)]);
|
||||
}
|
||||
|
||||
$response = $this->withSession(['share_password_'.$share->token => null])
|
||||
->get(route('share.download.all', $share));
|
||||
|
||||
$response->assertDownload();
|
||||
$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 produces a valid archive', function () {
|
||||
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');
|
||||
|
||||
$share = createShareWithFile();
|
||||
$share->load('files');
|
||||
|
||||
$encryptionService = app(FileEncryptionService::class);
|
||||
$key = $share->encryption_key;
|
||||
$content = 'hello zip content';
|
||||
|
||||
foreach ($share->files as $file) {
|
||||
$dir = Storage::disk('shares')->path($share->token);
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
$encryptedPath = $dir.'/'.basename($file->stored_path);
|
||||
$tempSource = tempnam(sys_get_temp_dir(), 'test');
|
||||
file_put_contents($tempSource, $content);
|
||||
$encryptionService->encryptFile($tempSource, $encryptedPath, $key);
|
||||
unlink($tempSource);
|
||||
$file->update(['file_size' => strlen($content)]);
|
||||
}
|
||||
|
||||
$response = $this->get(route('share.download.all', $share));
|
||||
$response->assertDownload();
|
||||
|
||||
$zipPath = $response->getFile()->getPathname();
|
||||
file_put_contents($zipPath, $this->get(route('share.download.all', $share))->streamedContent());
|
||||
|
||||
$zip = new ZipArchive;
|
||||
$result = $zip->open($zipPath);
|
||||
|
||||
expect($result)->toBe(true);
|
||||
expect($zip->numFiles)->toBe(1);
|
||||
expect($zip->statIndex(0)['size'])->toBe(strlen($content));
|
||||
|
||||
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('last download streams successfully before auto-delete', function () {
|
||||
Storage::fake('shares');
|
||||
|
||||
$share = createShareWithFile();
|
||||
$share->update(['max_downloads' => 1]);
|
||||
$share->load('files');
|
||||
|
||||
$encryptionService = app(FileEncryptionService::class);
|
||||
$key = $share->encryption_key;
|
||||
$content = 'last download content';
|
||||
$content = $this->get(route('share.download.all', $share))->streamedContent();
|
||||
|
||||
foreach ($share->files as $file) {
|
||||
$dir = Storage::disk('shares')->path($share->token);
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
$encryptedPath = $dir.'/'.basename($file->stored_path);
|
||||
$tempSource = tempnam(sys_get_temp_dir(), 'test');
|
||||
file_put_contents($tempSource, $content);
|
||||
$encryptionService->encryptFile($tempSource, $encryptedPath, $key);
|
||||
unlink($tempSource);
|
||||
$file->update(['file_size' => strlen($content)]);
|
||||
}
|
||||
expect($content)->toStartWith("PK\x03\x04");
|
||||
$this->assertModelMissing($share);
|
||||
});
|
||||
|
||||
$response = $this->get(route('share.download.all', $share));
|
||||
$response->assertDownload();
|
||||
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();
|
||||
|
||||
// Share was deleted after download
|
||||
expect(Share::query()->find($share->id))->toBeNull();
|
||||
$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');
|
||||
});
|
||||
|
||||
test('share auto-deletes after reaching download limit', function () {
|
||||
@@ -199,13 +177,10 @@ test('share auto-deletes after reaching download limit', function () {
|
||||
/**
|
||||
* Helper to create a share with an actual encrypted file.
|
||||
*/
|
||||
function createShareWithFile(?string $password = null): Share
|
||||
function createShareWithFile(?string $password = null, string $content = 'test content'): Share
|
||||
{
|
||||
$service = app(ShareService::class);
|
||||
$file = UploadedFile::fake()->create('testfile.txt', 100);
|
||||
|
||||
return $service->createShare([
|
||||
['file' => $file, 'relativePath' => null],
|
||||
return app(ShareService::class)->createShare([
|
||||
['file' => UploadedFile::fake()->createWithContent('testfile.txt', $content), 'relativePath' => null],
|
||||
], [
|
||||
'password' => $password,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* PUT a chunk's bytes as the uploader's page does, with the given pending shares in the session.
|
||||
*
|
||||
* @param array<int, string> $pendingShares
|
||||
*/
|
||||
function putChunk(mixed $test, ShareFile $file, int $index, string $chunk, array $pendingShares): mixed
|
||||
{
|
||||
return $test->withSession(['pending_shares' => $pendingShares])->call(
|
||||
'PUT',
|
||||
route('upload.chunk', ['shareFile' => $file, 'index' => $index]),
|
||||
server: ['CONTENT_TYPE' => 'application/octet-stream', 'HTTP_ACCEPT' => 'application/json'],
|
||||
content: $chunk,
|
||||
);
|
||||
}
|
||||
|
||||
test('a chunk for a pending share this session started is stored', function () {
|
||||
Storage::fake('shares');
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 6, null);
|
||||
|
||||
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, false), [$file->share->token]);
|
||||
|
||||
$response->assertOk()->assertExactJson(['uploaded_chunks' => 1]);
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(1);
|
||||
});
|
||||
|
||||
test('a chunk for a pending share another session started returns 404', function () {
|
||||
Storage::fake('shares');
|
||||
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
|
||||
|
||||
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, true), ['someOtherToken12']);
|
||||
|
||||
$response->assertNotFound();
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||
});
|
||||
|
||||
test('a chunk for a share that is already complete returns 404', function () {
|
||||
Storage::fake('shares');
|
||||
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
|
||||
$file->share->update(['completed_at' => now()]);
|
||||
|
||||
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, true), [$file->share->token]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('a chunk for a removed file returns 404', function () {
|
||||
Storage::fake('shares');
|
||||
$service = app(ShareService::class);
|
||||
$file = $service->registerFile(null, 'notes.txt', 4, null);
|
||||
$chunk = encryptedChunk($file, 'abcd', 0, true);
|
||||
$token = $file->share->token;
|
||||
$service->removeFile($file);
|
||||
|
||||
$response = putChunk($this, $file, 0, $chunk, [$token]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('a chunk that skips ahead returns 409 with the number of chunks stored', function () {
|
||||
Storage::fake('shares');
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 6, null);
|
||||
|
||||
$response = putChunk($this, $file, 1, encryptedChunk($file, 'ef', 1, true), [$file->share->token]);
|
||||
|
||||
$response->assertConflict()->assertExactJson(['uploaded_chunks' => 0]);
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||
});
|
||||
|
||||
test('a chunk sent again after it was stored is acknowledged without storing it twice', function () {
|
||||
Storage::fake('shares');
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 6, null);
|
||||
$chunk = encryptedChunk($file, 'abcd', 0, false);
|
||||
putChunk($this, $file, 0, $chunk, [$file->share->token]);
|
||||
|
||||
$response = putChunk($this, $file->refresh(), 0, $chunk, [$file->share->token]);
|
||||
|
||||
$response->assertOk()->assertExactJson(['uploaded_chunks' => 1]);
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(1);
|
||||
});
|
||||
|
||||
test('an invalid chunk returns 422 and is not stored', function () {
|
||||
Storage::fake('shares');
|
||||
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
|
||||
|
||||
$response = putChunk($this, $file, 0, str_repeat("\0", 20), [$file->share->token]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||
});
|
||||
|
||||
test('a chunk is refused until the system password was entered', function () {
|
||||
Storage::fake('shares');
|
||||
Setting::set('system_password', bcrypt('system-secret'));
|
||||
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
|
||||
|
||||
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, true), [$file->share->token]);
|
||||
|
||||
$response->assertRedirect(route('system-password'));
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ShareFile;
|
||||
use App\Models\User;
|
||||
use App\Services\FileEncryptionService;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -63,3 +66,14 @@ function ready(mixed $page): mixed
|
||||
return $page->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
/**
|
||||
* A chunk of a registered file, encrypted with its share's key and its header's nonce prefix.
|
||||
*/
|
||||
function encryptedChunk(ShareFile $file, string $plaintext, int $index, bool $isLast): string
|
||||
{
|
||||
$encryption = app(FileEncryptionService::class);
|
||||
$header = $encryption->parseHeader(file_get_contents(app(ShareService::class)->storedFilePath($file), false, null, 0, FileEncryptionService::HEADER_LENGTH));
|
||||
|
||||
return $encryption->encryptChunk($plaintext, $file->share->encryption_key, $header['noncePrefix'], $index, $isLast);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Share;
|
||||
use App\Services\QrCodeService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Tests\Screenshots\DemoData;
|
||||
use Tests\Screenshots\Publisher;
|
||||
|
||||
@@ -19,14 +18,9 @@ beforeEach(function () {
|
||||
// Sessions have to outlive a request: a sign-in, an unlocked share.
|
||||
config(['session.driver' => 'file']);
|
||||
Storage::fake('shares');
|
||||
Storage::fake('tmp-for-tests');
|
||||
|
||||
$this->travelTo(Carbon::parse('2026-10-01 09:30'));
|
||||
|
||||
// Livewire deletes temporary uploads a day older than now, by the files' real modification
|
||||
// times: under the frozen clock that is every file this run stores.
|
||||
config(['livewire.temporary_file_upload.cleanup' => false]);
|
||||
|
||||
DemoData::shares();
|
||||
});
|
||||
|
||||
@@ -71,27 +65,36 @@ function shoot(mixed $page, string $device, string $theme, string $name): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Put files into the uploader the way a finished upload does: stored as Livewire temporary uploads
|
||||
* and handed to `_finishUpload` by their signed names. A browser test cannot select files through
|
||||
* the file input, because the in-process server does not store a multipart upload.
|
||||
* Put files into the uploader the way a finished upload does: registered through the page's own
|
||||
* `registerFiles`, their encrypted content stored here as the browser would have sent it (zeros of
|
||||
* the demo size), and the list refreshed to show them uploaded.
|
||||
*
|
||||
* @param array<string, int> $files relative path => size in kilobytes
|
||||
*/
|
||||
function selectFiles(mixed $page, array $files): void
|
||||
{
|
||||
$signed = [];
|
||||
$selection = collect($files)->map(fn (int $kilobytes, string $path): array => [
|
||||
'name' => basename($path),
|
||||
'size' => $kilobytes * 1024,
|
||||
'path' => str_contains($path, '/') ? $path : null,
|
||||
])->values();
|
||||
$wire = 'Livewire.find(document.querySelector("[data-test=drop-zone]").closest("[wire\\\\:id]").getAttribute("wire:id"))';
|
||||
|
||||
foreach ($files as $path => $kilobytes) {
|
||||
// Livewire's own storage for an arriving upload: the file and its name, type and size beside it.
|
||||
$stored = FileUploadConfiguration::storeTemporaryFile(UploadedFile::fake()->create(basename($path), $kilobytes), 'tmp-for-tests');
|
||||
$page->script('(async () => { await '.$wire.'.registerFiles('.json_encode($selection).') })()');
|
||||
$page->waitForText('Selected Files ('.count($files).')');
|
||||
|
||||
$signed[] = TemporaryUploadedFile::signPath(basename($stored));
|
||||
$shareService = app(ShareService::class);
|
||||
|
||||
foreach (Share::query()->whereNull('completed_at')->latest('id')->firstOrFail()->files as $file) {
|
||||
$header = $shareService->readHeader($file);
|
||||
|
||||
for ($index = 0; $index < $header['chunkCount']; $index++) {
|
||||
$length = min($header['chunkSize'], $file->file_size - $index * $header['chunkSize']);
|
||||
$shareService->storeChunk($file->refresh(), $index, encryptedChunk($file, str_repeat("\0", $length), $index, $index === $header['chunkCount'] - 1));
|
||||
}
|
||||
}
|
||||
|
||||
$relativePaths = array_map(fn (string $path): ?string => str_contains($path, '/') ? $path : null, array_keys($files));
|
||||
|
||||
$page->script('(() => { const wire = Livewire.find(document.querySelector("[data-test=drop-zone]").closest("[wire\\\\:id]").getAttribute("wire:id")); wire.$set("relativePaths", '.json_encode($relativePaths).', false); wire._finishUpload("files", '.json_encode($signed).', true) })()');
|
||||
|
||||
$page->script('(async () => { await '.$wire.'.$refresh() })()');
|
||||
$page->assertSee('Selected Files ('.count($files).')');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Services\FileEncryptionService;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->service = new FileEncryptionService;
|
||||
@@ -16,6 +15,27 @@ afterEach(function () {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The whole decrypted content of an encrypted file.
|
||||
*/
|
||||
function decryptToString(FileEncryptionService $service, string $path, string $key): string
|
||||
{
|
||||
return implode('', iterator_to_array($service->decryptedChunks($path, $key), false));
|
||||
}
|
||||
|
||||
/**
|
||||
* A chunk encrypted the way the uploader's browser does, built here without the service: the
|
||||
* nonce is prefix, index and last-chunk flag; the tag follows the ciphertext.
|
||||
*/
|
||||
function browserChunk(string $plaintext, string $keyHex, string $noncePrefix, int $index, bool $isLast): string
|
||||
{
|
||||
$tag = '';
|
||||
$nonce = $noncePrefix.pack('N', $index).($isLast ? "\x01" : "\x00");
|
||||
$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', hex2bin($keyHex), OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
||||
|
||||
return $ciphertext.$tag;
|
||||
}
|
||||
|
||||
test('encrypt and decrypt round-trip works', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||
@@ -25,14 +45,10 @@ test('encrypt and decrypt round-trip works', function () {
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1024);
|
||||
|
||||
expect(file_exists($encryptedPath))->toBeTrue();
|
||||
expect(file_get_contents($encryptedPath))->not->toBe($content);
|
||||
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
|
||||
expect($decrypted)->toBe($content);
|
||||
expect(file_get_contents($encryptedPath))->not->toContain($content);
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||
});
|
||||
|
||||
test('decrypt with wrong key fails', function () {
|
||||
@@ -41,12 +57,9 @@ test('decrypt with wrong key fails', function () {
|
||||
|
||||
file_put_contents($sourcePath, 'Secret data');
|
||||
|
||||
$correctKey = $this->service->generateRandomKey();
|
||||
$wrongKey = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024);
|
||||
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $correctKey);
|
||||
|
||||
$this->service->decryptFile($encryptedPath, $wrongKey);
|
||||
decryptToString($this->service, $encryptedPath, $this->service->generateRandomKey());
|
||||
})->throws(RuntimeException::class, 'Decryption failed');
|
||||
|
||||
test('derive key produces consistent results', function () {
|
||||
@@ -98,78 +111,50 @@ test('password-derived key encrypt/decrypt round-trip works', function () {
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$password = 'user-password';
|
||||
$salt = $this->service->generateSalt();
|
||||
$key = bin2hex($this->service->deriveKey($password, $salt));
|
||||
$key = bin2hex($this->service->deriveKey('user-password', $this->service->generateSalt()));
|
||||
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1024);
|
||||
|
||||
expect($decrypted)->toBe($content);
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||
});
|
||||
|
||||
test('decrypt file stream returns streamed response', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||
$content = 'Streamed content';
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
|
||||
$response = $this->service->decryptFileStream($encryptedPath, $key, 'test.txt', 'text/plain');
|
||||
|
||||
expect($response)->toBeInstanceOf(StreamedResponse::class);
|
||||
expect($response->headers->get('Content-Type'))->toBe('text/plain');
|
||||
expect($response->headers->get('Content-Disposition'))->toContain('test.txt');
|
||||
});
|
||||
|
||||
test('chunked file has SEALCHK1 magic header', function () {
|
||||
test('an encrypted file starts with the SEALCHK2 header and its chunk size', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||
|
||||
file_put_contents($sourcePath, 'test content');
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1024);
|
||||
|
||||
$header = file_get_contents($encryptedPath, false, null, 0, 8);
|
||||
|
||||
expect($header)->toBe('SEALCHK1');
|
||||
expect(file_get_contents($encryptedPath, false, null, 0, 12))->toBe('SEALCHK2'.pack('N', 1024));
|
||||
});
|
||||
|
||||
test('multi-chunk round-trip works', function () {
|
||||
$sourcePath = $this->tempDir.'/large.bin';
|
||||
$encryptedPath = $this->tempDir.'/large.enc';
|
||||
|
||||
// Create a file larger than one 4 MB chunk (5 MB)
|
||||
$chunkSize = 4 * 1024 * 1024;
|
||||
$content = random_bytes($chunkSize + (1024 * 1024));
|
||||
$content = random_bytes(2500);
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
|
||||
|
||||
expect($decrypted)->toBe($content);
|
||||
expect(filesize($encryptedPath))->toBe(19 + 3 * 16 + 2500);
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||
});
|
||||
|
||||
test('exact chunk boundary round-trip works', function () {
|
||||
$sourcePath = $this->tempDir.'/exact.bin';
|
||||
$encryptedPath = $this->tempDir.'/exact.enc';
|
||||
|
||||
// Create a file exactly equal to one chunk (4 MB)
|
||||
$content = random_bytes(4 * 1024 * 1024);
|
||||
$content = random_bytes(2000);
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
|
||||
|
||||
expect($decrypted)->toBe($content);
|
||||
expect(filesize($encryptedPath))->toBe(19 + 2 * 16 + 2000);
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||
});
|
||||
|
||||
test('empty file round-trip works', function () {
|
||||
@@ -179,58 +164,123 @@ test('empty file round-trip works', function () {
|
||||
file_put_contents($sourcePath, '');
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
|
||||
|
||||
expect($decrypted)->toBe('');
|
||||
expect(filesize($encryptedPath))->toBe(19 + 16);
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe('');
|
||||
});
|
||||
|
||||
test('chunks encrypted the way the browser does decrypt to the original file', function () {
|
||||
$key = $this->service->generateRandomKey();
|
||||
$header = $this->service->createHeader(4);
|
||||
$noncePrefix = substr($header, 12, 7);
|
||||
$encryptedPath = $this->tempDir.'/browser.enc';
|
||||
|
||||
file_put_contents($encryptedPath, $header
|
||||
.browserChunk('abcd', $key, $noncePrefix, 0, false)
|
||||
.browserChunk('ef', $key, $noncePrefix, 1, true));
|
||||
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe('abcdef');
|
||||
});
|
||||
|
||||
test('a chunk with the wrong last-chunk flag is rejected', function () {
|
||||
$key = $this->service->generateRandomKey();
|
||||
$noncePrefix = random_bytes(7);
|
||||
|
||||
$this->service->decryptChunk(browserChunk('abcd', $key, $noncePrefix, 0, false), $key, $noncePrefix, 0, true);
|
||||
})->throws(RuntimeException::class, 'Decryption failed');
|
||||
|
||||
test('a file cut short at a chunk boundary fails to decrypt', function () {
|
||||
$sourcePath = $this->tempDir.'/source.bin';
|
||||
$encryptedPath = $this->tempDir.'/truncated.enc';
|
||||
|
||||
file_put_contents($sourcePath, random_bytes(3000));
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key, 1000);
|
||||
|
||||
$handle = fopen($encryptedPath, 'r+b');
|
||||
ftruncate($handle, 19 + 2 * (1000 + 16));
|
||||
fclose($handle);
|
||||
|
||||
decryptToString($this->service, $encryptedPath, $key);
|
||||
})->throws(RuntimeException::class, 'Decryption failed');
|
||||
|
||||
test('a file with two chunks swapped fails to decrypt', function () {
|
||||
$key = $this->service->generateRandomKey();
|
||||
$header = $this->service->createHeader(4);
|
||||
$noncePrefix = substr($header, 12, 7);
|
||||
$encryptedPath = $this->tempDir.'/swapped.enc';
|
||||
|
||||
file_put_contents($encryptedPath, $header
|
||||
.browserChunk('efgh', $key, $noncePrefix, 1, false)
|
||||
.browserChunk('abcd', $key, $noncePrefix, 0, false)
|
||||
.browserChunk('ij', $key, $noncePrefix, 2, true));
|
||||
|
||||
decryptToString($this->service, $encryptedPath, $key);
|
||||
})->throws(RuntimeException::class, 'Decryption failed');
|
||||
|
||||
test('SEALCHK1 files from before still decrypt', function () {
|
||||
$key = $this->service->generateRandomKey();
|
||||
$encryptedPath = $this->tempDir.'/sealchk1.enc';
|
||||
$baseNonce = random_bytes(12);
|
||||
$file = 'SEALCHK1'.pack('N', 4).$baseNonce;
|
||||
|
||||
foreach (['abcd', 'ef'] as $index => $plaintext) {
|
||||
$nonce = $baseNonce;
|
||||
$indexBytes = pack('N', $index);
|
||||
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$nonce[8 + $i] = $nonce[8 + $i] ^ $indexBytes[$i];
|
||||
}
|
||||
|
||||
$tag = '';
|
||||
$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', hex2bin($key), OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
||||
$file .= $tag.$ciphertext;
|
||||
}
|
||||
|
||||
file_put_contents($encryptedPath, $file);
|
||||
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe('abcdef');
|
||||
});
|
||||
|
||||
test('legacy format backward compatibility', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/legacy.enc';
|
||||
$content = 'Legacy encrypted content';
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$binaryKey = hex2bin($key);
|
||||
|
||||
// Manually create a legacy format file: [nonce][tag][ciphertext]
|
||||
$nonce = random_bytes(12);
|
||||
$tag = '';
|
||||
$ciphertext = openssl_encrypt($content, 'aes-256-gcm', $binaryKey, OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
||||
$ciphertext = openssl_encrypt($content, 'aes-256-gcm', hex2bin($key), OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
|
||||
file_put_contents($encryptedPath, $nonce.$tag.$ciphertext);
|
||||
|
||||
$decrypted = $this->service->decryptFile($encryptedPath, $key);
|
||||
|
||||
expect($decrypted)->toBe($content);
|
||||
expect(decryptToString($this->service, $encryptedPath, $key))->toBe($content);
|
||||
});
|
||||
|
||||
test('wrong key on chunked file throws exception', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||
|
||||
file_put_contents($sourcePath, 'Chunked secret data');
|
||||
file_put_contents($sourcePath, random_bytes(2500));
|
||||
|
||||
$correctKey = $this->service->generateRandomKey();
|
||||
$wrongKey = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $this->service->generateRandomKey(), 1000);
|
||||
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $correctKey);
|
||||
|
||||
$this->service->decryptFile($encryptedPath, $wrongKey);
|
||||
decryptToString($this->service, $encryptedPath, $this->service->generateRandomKey());
|
||||
})->throws(RuntimeException::class, 'Decryption failed');
|
||||
|
||||
test('decrypt file stream with file size sets content-length header', function () {
|
||||
$sourcePath = $this->tempDir.'/source.txt';
|
||||
$encryptedPath = $this->tempDir.'/encrypted.enc';
|
||||
$content = 'Content with known size';
|
||||
test('a wrapped key unwraps with its password to the same data key', function () {
|
||||
$dataKey = $this->service->generateRandomKey();
|
||||
|
||||
file_put_contents($sourcePath, $content);
|
||||
$wrapped = $this->service->wrapKey($dataKey, 'correct horse battery');
|
||||
|
||||
$key = $this->service->generateRandomKey();
|
||||
$this->service->encryptFile($sourcePath, $encryptedPath, $key);
|
||||
|
||||
$response = $this->service->decryptFileStream($encryptedPath, $key, 'test.txt', 'text/plain', strlen($content));
|
||||
|
||||
expect($response->headers->get('Content-Length'))->toBe((string) strlen($content));
|
||||
expect($wrapped)->toStartWith('argon2id$')->not->toContain($dataKey);
|
||||
expect($this->service->unwrapKey($wrapped, 'correct horse battery'))->toBe($dataKey);
|
||||
});
|
||||
|
||||
test('a wrapped key does not unwrap with a wrong password', function () {
|
||||
$wrapped = $this->service->wrapKey($this->service->generateRandomKey(), 'correct horse battery');
|
||||
|
||||
$this->service->unwrapKey($wrapped, 'wrong horse battery');
|
||||
})->throws(RuntimeException::class, 'Unwrapping failed');
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Models\ShareFile;
|
||||
use App\Services\FileEncryptionService;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
pest()->extend(TestCase::class)
|
||||
@@ -29,7 +32,7 @@ test('create share without password stores encryption key', function () {
|
||||
expect($share->token)->toHaveLength(16);
|
||||
expect($share->password)->toBeNull();
|
||||
expect($share->encryption_key)->not->toBeNull();
|
||||
expect($share->encryption_salt)->not->toBeNull();
|
||||
expect($share->wrapped_key)->toBeNull();
|
||||
expect($share->files)->toHaveCount(1);
|
||||
expect($share->files->first()->original_name)->toBe('document.pdf');
|
||||
});
|
||||
@@ -161,8 +164,8 @@ test('get decryption key returns stored key for non-password share', function ()
|
||||
expect(strlen($key))->toBe(64);
|
||||
});
|
||||
|
||||
test('get decryption key derives key for password share', function () {
|
||||
$file = UploadedFile::fake()->create('file.txt', 100);
|
||||
test('get decryption key unwraps the data key of a password share, which decrypts its files', function () {
|
||||
$file = UploadedFile::fake()->createWithContent('file.txt', 'the secret contents');
|
||||
|
||||
$share = $this->service->createShare([
|
||||
['file' => $file, 'relativePath' => null],
|
||||
@@ -172,8 +175,17 @@ test('get decryption key derives key for password share', function () {
|
||||
|
||||
$key = $this->service->getDecryptionKey($share, 'test-password');
|
||||
|
||||
expect($key)->not->toBeNull();
|
||||
expect(strlen($key))->toBe(64);
|
||||
$decrypted = implode('', iterator_to_array(app(FileEncryptionService::class)->decryptedChunks($this->service->storedFilePath($share->files->first()), $key), false));
|
||||
expect($decrypted)->toBe('the secret contents');
|
||||
});
|
||||
|
||||
test('get decryption key derives the key of a password share created before key wrapping', function () {
|
||||
$salt = str_repeat('ab', 32);
|
||||
$share = Share::factory()->withPassword('old-password')->create(['encryption_salt' => $salt]);
|
||||
|
||||
$key = $this->service->getDecryptionKey($share, 'old-password');
|
||||
|
||||
expect($key)->toBe(hash_pbkdf2('sha256', 'old-password', hex2bin($salt), 100000, 64));
|
||||
});
|
||||
|
||||
test('get decryption key throws for password share without password', function () {
|
||||
@@ -187,3 +199,175 @@ test('get decryption key throws for password share without password', function (
|
||||
|
||||
$this->service->getDecryptionKey($share);
|
||||
})->throws(RuntimeException::class, 'Password required');
|
||||
|
||||
test('registering a file starts a pending share with the file\'s encrypted header on disk', function () {
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
|
||||
$file = $this->service->registerFile(null, 'report.pdf', 10, 'reports/report.pdf');
|
||||
|
||||
expect($file->share->isCompleted())->toBeFalse();
|
||||
expect($file->share->total_size)->toBe(10);
|
||||
expect($file->relative_path)->toBe('reports/report.pdf');
|
||||
expect($file->completed_at)->toBeNull();
|
||||
expect(file_get_contents($this->service->storedFilePath($file), false, null, 0, 12))->toBe('SEALCHK2'.pack('N', 4));
|
||||
});
|
||||
|
||||
test('a second registered file joins the same pending share and adds its size', function () {
|
||||
$first = $this->service->registerFile(null, 'one.txt', 10, null);
|
||||
|
||||
$second = $this->service->registerFile($first->share, 'two.txt', 20, null);
|
||||
|
||||
expect($second->share_id)->toBe($first->share_id);
|
||||
expect($second->share->total_size)->toBe(30);
|
||||
});
|
||||
|
||||
test('a file larger than the admin file size limit is rejected', function () {
|
||||
Setting::set('max_file_size', 5 * 1024 * 1024);
|
||||
|
||||
expect(fn () => $this->service->registerFile(null, 'big.iso', 6 * 1024 * 1024, null))
|
||||
->toThrow(ValidationException::class, '"big.iso" is too large (6 MB). Maximum file size is 5 MB.');
|
||||
expect(Share::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('a file beyond the admin limit of files per share is rejected', function () {
|
||||
Setting::set('max_files_per_share', 1);
|
||||
$first = $this->service->registerFile(null, 'one.txt', 10, null);
|
||||
|
||||
expect(fn () => $this->service->registerFile($first->share, 'two.txt', 10, null))
|
||||
->toThrow(ValidationException::class, 'Too many files. Maximum 1 files allowed per share.');
|
||||
expect(ShareFile::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('a file that takes the share beyond the admin size per share is rejected', function () {
|
||||
Setting::set('max_size_per_share', 25);
|
||||
$first = $this->service->registerFile(null, 'one.txt', 20, null);
|
||||
|
||||
expect(fn () => $this->service->registerFile($first->share, 'two.txt', 10, null))
|
||||
->toThrow(ValidationException::class, 'Total file size exceeds the maximum allowed per share.');
|
||||
});
|
||||
|
||||
test('a file that does not fit the storage quota beside files still uploading is rejected', function () {
|
||||
Setting::set('max_storage_quota', 100);
|
||||
Share::factory()->pending()->create(['total_size' => 60]);
|
||||
|
||||
expect(fn () => $this->service->registerFile(null, 'file.txt', 50, null))
|
||||
->toThrow(ValidationException::class, 'Storage is full. Please contact the administrator.');
|
||||
});
|
||||
|
||||
test('chunks stored in order complete the file', function () {
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
|
||||
|
||||
$afterFirst = $this->service->storeChunk($file, 0, encryptedChunk($file, 'abcd', 0, false));
|
||||
$afterLast = $this->service->storeChunk($file->refresh(), 1, encryptedChunk($file, 'ef', 1, true));
|
||||
|
||||
expect([$afterFirst, $afterLast])->toBe([1, 2]);
|
||||
expect($file->refresh()->completed_at)->not->toBeNull();
|
||||
$decrypted = implode('', iterator_to_array(app(FileEncryptionService::class)->decryptedChunks($this->service->storedFilePath($file), $file->share->encryption_key), false));
|
||||
expect($decrypted)->toBe('abcdef');
|
||||
});
|
||||
|
||||
test('a chunk stored a second time is counted once', function () {
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
|
||||
$chunk = encryptedChunk($file, 'abcd', 0, false);
|
||||
$this->service->storeChunk($file, 0, $chunk);
|
||||
|
||||
$uploadedChunks = $this->service->storeChunk($file, 0, $chunk);
|
||||
|
||||
expect($uploadedChunks)->toBe(1);
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(1);
|
||||
});
|
||||
|
||||
test('a chunk with the wrong length is rejected', function () {
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
|
||||
|
||||
expect(fn () => $this->service->storeChunk($file, 0, encryptedChunk($file, 'abc', 0, false)))
|
||||
->toThrow(InvalidArgumentException::class, 'wrong length');
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||
});
|
||||
|
||||
test('a chunk that fails authentication is rejected', function () {
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
|
||||
$chunk = encryptedChunk($file, 'abcd', 0, false);
|
||||
$chunk[0] = $chunk[0] ^ "\x01";
|
||||
|
||||
expect(fn () => $this->service->storeChunk($file, 0, $chunk))
|
||||
->toThrow(InvalidArgumentException::class, 'failed authentication');
|
||||
expect($file->refresh()->uploaded_chunks)->toBe(0);
|
||||
});
|
||||
|
||||
test('a chunk beyond the end of the file is rejected', function () {
|
||||
config(['uploads.chunk_size' => 4]);
|
||||
$file = $this->service->registerFile(null, 'notes.txt', 4, null);
|
||||
|
||||
expect(fn () => $this->service->storeChunk($file, 1, encryptedChunk($file, 'abcd', 1, true)))
|
||||
->toThrow(InvalidArgumentException::class, 'beyond the end');
|
||||
});
|
||||
|
||||
test('the MIME type is detected from the first chunk\'s content', function () {
|
||||
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==');
|
||||
$file = $this->service->registerFile(null, 'photo.bin', strlen($png), null);
|
||||
|
||||
$this->service->storeChunk($file, 0, encryptedChunk($file, $png, 0, true));
|
||||
|
||||
expect($file->refresh()->mime_type)->toBe('image/png');
|
||||
});
|
||||
|
||||
test('a chunk for a removed file is rejected', function () {
|
||||
$file = $this->service->registerFile(null, 'notes.txt', 4, null);
|
||||
$chunk = encryptedChunk($file, 'abcd', 0, true);
|
||||
$this->service->removeFile($file);
|
||||
|
||||
$this->service->storeChunk($file, 0, $chunk);
|
||||
})->throws(ModelNotFoundException::class);
|
||||
|
||||
test('removing a file deletes it and gives its size back', function () {
|
||||
$keep = $this->service->registerFile(null, 'keep.txt', 10, null);
|
||||
$remove = $this->service->registerFile($keep->share, 'remove.txt', 20, null);
|
||||
$path = $this->service->storedFilePath($remove);
|
||||
|
||||
$this->service->removeFile($remove);
|
||||
|
||||
expect($keep->share->refresh()->total_size)->toBe(10);
|
||||
expect(file_exists($path))->toBeFalse();
|
||||
$this->assertModelMissing($remove);
|
||||
});
|
||||
|
||||
test('completing a share with a file still uploading is rejected', function () {
|
||||
$file = $this->service->registerFile(null, 'notes.txt', 4, null);
|
||||
|
||||
expect(fn () => $this->service->completeShare($file->share))
|
||||
->toThrow(ValidationException::class, 'Wait until every file has finished uploading, or remove the ones that failed.');
|
||||
expect($file->share->refresh()->isCompleted())->toBeFalse();
|
||||
});
|
||||
|
||||
test('completing a share without files is rejected', function () {
|
||||
$share = Share::factory()->pending()->create();
|
||||
|
||||
expect(fn () => $this->service->completeShare($share))
|
||||
->toThrow(ValidationException::class, 'Please select at least one file to upload.');
|
||||
});
|
||||
|
||||
test('completing a share with a password wraps its data key instead of storing it', function () {
|
||||
$file = $this->service->registerFile(null, 'notes.txt', 4, null);
|
||||
$this->service->storeChunk($file, 0, encryptedChunk($file, 'abcd', 0, true));
|
||||
$dataKey = $file->share->encryption_key;
|
||||
|
||||
$share = $this->service->completeShare($file->share, ['password' => 'a-long-password']);
|
||||
|
||||
expect($share->refresh()->isCompleted())->toBeTrue();
|
||||
expect($share->encryption_key)->toBeNull();
|
||||
expect(app(FileEncryptionService::class)->unwrapKey($share->wrapped_key, 'a-long-password'))->toBe($dataKey);
|
||||
});
|
||||
|
||||
test('create share stores an empty file', function () {
|
||||
$share = $this->service->createShare([
|
||||
['file' => UploadedFile::fake()->createWithContent('empty.txt', ''), 'relativePath' => null],
|
||||
]);
|
||||
|
||||
expect($share->files->first()->completed_at)->not->toBeNull();
|
||||
expect(filesize($this->service->storedFilePath($share->files->first())))->toBe(FileEncryptionService::HEADER_LENGTH + FileEncryptionService::TAG_LENGTH);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user