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
@@ -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