A share can now hold a private text (a password, a key, a short note) with its files or on its own. The upload page's new "Private text" card takes up to 100 KB; the browser encrypts the text and sends it through the same chunk pipeline as a file, flagged is_text on share_files, so it gets the share's password, expiry, download limit and cleanup. The recipient sees the text only after pressing "Show text", which counts as their download, so a messenger link preview cannot use up a share limited to one download. The text is left out of the file list, the ZIP and the file counts; the admin dashboard marks shares that hold one with "Text". The website gains a Private text feature card and a fifth phone screenshot; every screenshot is retaken. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
402 lines
16 KiB
PHP
402 lines
16 KiB
PHP
<?php
|
|
|
|
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)
|
|
->use(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Storage::fake('shares');
|
|
$this->service = app(ShareService::class);
|
|
});
|
|
|
|
test('create share without password stores encryption key', function () {
|
|
$file = UploadedFile::fake()->create('document.pdf', 1024);
|
|
|
|
$share = $this->service->createShare([
|
|
['file' => $file, 'relativePath' => null],
|
|
]);
|
|
|
|
expect($share)->toBeInstanceOf(Share::class);
|
|
expect($share->token)->toHaveLength(16);
|
|
expect($share->password)->toBeNull();
|
|
expect($share->encryption_key)->not->toBeNull();
|
|
expect($share->wrapped_key)->toBeNull();
|
|
expect($share->files)->toHaveCount(1);
|
|
expect($share->files->first()->original_name)->toBe('document.pdf');
|
|
});
|
|
|
|
test('create share with password does not store encryption key', function () {
|
|
$file = UploadedFile::fake()->create('secret.txt', 512);
|
|
|
|
$share = $this->service->createShare([
|
|
['file' => $file, 'relativePath' => null],
|
|
], [
|
|
'password' => 'my-password',
|
|
]);
|
|
|
|
expect($share->password)->not->toBeNull();
|
|
expect($share->encryption_key)->toBeNull();
|
|
expect(Hash::check('my-password', $share->password))->toBeTrue();
|
|
});
|
|
|
|
test('create share with options sets expiration and max downloads', function () {
|
|
$file = UploadedFile::fake()->create('file.txt', 256);
|
|
|
|
$share = $this->service->createShare([
|
|
['file' => $file, 'relativePath' => null],
|
|
], [
|
|
'expires_at' => now()->addDay(),
|
|
'max_downloads' => 5,
|
|
]);
|
|
|
|
expect($share->expires_at)->not->toBeNull();
|
|
expect($share->max_downloads)->toBe(5);
|
|
});
|
|
|
|
test('create share with multiple files', function () {
|
|
$file1 = UploadedFile::fake()->create('file1.txt', 100);
|
|
$file2 = UploadedFile::fake()->create('file2.txt', 200);
|
|
|
|
$share = $this->service->createShare([
|
|
['file' => $file1, 'relativePath' => 'folder/file1.txt'],
|
|
['file' => $file2, 'relativePath' => 'folder/file2.txt'],
|
|
]);
|
|
|
|
expect($share->files)->toHaveCount(2);
|
|
expect($share->files->first()->relative_path)->toBe('folder/file1.txt');
|
|
});
|
|
|
|
test('delete share removes files and database records', function () {
|
|
$file = UploadedFile::fake()->create('file.txt', 100);
|
|
|
|
$share = $this->service->createShare([
|
|
['file' => $file, 'relativePath' => null],
|
|
]);
|
|
|
|
$shareId = $share->id;
|
|
$token = $share->token;
|
|
|
|
$this->service->deleteShare($share);
|
|
|
|
expect(Share::query()->find($shareId))->toBeNull();
|
|
expect(ShareFile::query()->where('share_id', $shareId)->count())->toBe(0);
|
|
expect(Storage::disk('shares')->directories())->not->toContain($token);
|
|
});
|
|
|
|
test('verify password returns true for correct password', function () {
|
|
$file = UploadedFile::fake()->create('file.txt', 100);
|
|
|
|
$share = $this->service->createShare([
|
|
['file' => $file, 'relativePath' => null],
|
|
], [
|
|
'password' => 'correct-password',
|
|
]);
|
|
|
|
expect($this->service->verifyPassword($share, 'correct-password'))->toBeTrue();
|
|
expect($this->service->verifyPassword($share, 'wrong-password'))->toBeFalse();
|
|
});
|
|
|
|
test('verify password returns true for non-password share', function () {
|
|
$file = UploadedFile::fake()->create('file.txt', 100);
|
|
|
|
$share = $this->service->createShare([
|
|
['file' => $file, 'relativePath' => null],
|
|
]);
|
|
|
|
expect($this->service->verifyPassword($share, 'any'))->toBeTrue();
|
|
});
|
|
|
|
test('get total used space sums share sizes', function () {
|
|
Share::factory()->create(['total_size' => 1000]);
|
|
Share::factory()->create(['total_size' => 2000]);
|
|
|
|
expect($this->service->getTotalUsedSpace())->toBe(3000);
|
|
});
|
|
|
|
test('is storage full checks against quota', function () {
|
|
Setting::set('max_storage_quota', 1000);
|
|
|
|
Share::factory()->create(['total_size' => 999]);
|
|
expect($this->service->isStorageFull())->toBeFalse();
|
|
|
|
Share::factory()->create(['total_size' => 1]);
|
|
expect($this->service->isStorageFull())->toBeTrue();
|
|
});
|
|
|
|
test('get decryption key returns stored key for non-password share', function () {
|
|
$file = UploadedFile::fake()->create('file.txt', 100);
|
|
|
|
$share = $this->service->createShare([
|
|
['file' => $file, 'relativePath' => null],
|
|
]);
|
|
|
|
$key = $this->service->getDecryptionKey($share);
|
|
|
|
expect($key)->not->toBeNull();
|
|
expect(strlen($key))->toBe(64);
|
|
});
|
|
|
|
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],
|
|
], [
|
|
'password' => 'test-password',
|
|
]);
|
|
|
|
$key = $this->service->getDecryptionKey($share, 'test-password');
|
|
|
|
$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 () {
|
|
$file = UploadedFile::fake()->create('file.txt', 100);
|
|
|
|
$share = $this->service->createShare([
|
|
['file' => $file, 'relativePath' => null],
|
|
], [
|
|
'password' => 'test-password',
|
|
]);
|
|
|
|
$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, 'Add files or a text to share.');
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
test('a private text over the maximum length is rejected', function () {
|
|
$text = str_repeat('a', ShareFile::MAX_TEXT_BYTES + 1);
|
|
|
|
expect(fn () => $this->service->registerFile(null, 'text.txt', strlen($text), null, isText: true))
|
|
->toThrow(ValidationException::class, 'The text is too long (maximum 100 KB).');
|
|
expect(Share::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('a private text does not count against the admin limit of files per share', function () {
|
|
Setting::set('max_files_per_share', 1);
|
|
$file = $this->service->registerFile(null, 'one.txt', 10, null);
|
|
|
|
$text = $this->service->registerFile($file->share, 'text.txt', 20, null, isText: true);
|
|
|
|
expect($text->share_id)->toBe($file->share_id);
|
|
expect(ShareFile::query()->count())->toBe(2);
|
|
});
|
|
|
|
test('create share with no files and a text completes a text-only share', function () {
|
|
$share = $this->service->createShare([], [], 'hi');
|
|
|
|
expect($share->isCompleted())->toBeTrue();
|
|
expect($share->files)->toHaveCount(1);
|
|
expect($share->files->first()->is_text)->toBeTrue();
|
|
expect($share->total_size)->toBe(2);
|
|
});
|
|
|
|
test('readText decrypts a share\'s private text without a password', function () {
|
|
$share = $this->service->createShare([], [], 'the secret note');
|
|
|
|
$text = $this->service->readText($share, $share->encryption_key);
|
|
|
|
expect($text)->toBe('the secret note');
|
|
});
|
|
|
|
test('readText decrypts a share\'s private text with the unwrapped key of a password share', function () {
|
|
$share = $this->service->createShare([], ['password' => 'a-long-password'], 'the secret note');
|
|
$key = $this->service->getDecryptionKey($share, 'a-long-password');
|
|
|
|
$text = $this->service->readText($share, $key);
|
|
|
|
expect($text)->toBe('the secret note');
|
|
});
|