With a download limit of 1, downloading one file of a share with several files deleted the share and the files not yet downloaded. ShareService::recordDownload() ran at the end of every download request, one file or the ZIP alike, and deleted the share as soon as download_count reached max_downloads. It has worked that way since the first commit. - One recipient's visit is one download. The first file or ZIP a session downloads is counted when it starts, in one conditional UPDATE that also checks the limit, so two recipients starting at once can't both take the last download. The session remembers the time, and for ShareService::DOWNLOAD_WINDOW_MINUTES (60) it may start more downloads of the share without counting them, even once the limit is reached. The claim happens in the controller before streaming, because the session is saved before the body is sent, and after the share key is resolved, so a request without the key uses nothing. - A share at its limit is closed to everyone else at once. The hourly cleanup deletes it 24 hours after shares.last_downloaded_at (new column), since a ZIP opens each file only when it reaches it and a large download can outlast the hour. - The download page of a limited share says how many downloads are left, switches to "You have 1 hour" on the first press (Alpine, as a download link does not render the page again), and shows the time left on the next visit. - The admin dashboard shows "2 of 3 downloads", marks shares at their limit "Download limit reached" and leaves them out of Active Shares. - Tests: the regression (3 files, limit 1: every file and the ZIP download, counted once), another recipient, the end of the hour, the last download going to one of two recipients, requests refused before streaming, unlimited shares, the page notes in PHP and in Chromium, the dashboard, and the cleanup at 23 and 25 hours. The tests of recordDownload() and of the instant deletion are gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
252 lines
13 KiB
PHP
252 lines
13 KiB
PHP
<?php
|
|
|
|
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;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
|
|
|
beforeEach(function () {
|
|
// Sessions have to outlive a request here: a sign-in, a verified share password.
|
|
config(['session.driver' => 'file']);
|
|
Storage::fake('shares');
|
|
});
|
|
|
|
test('files dragged over the drop zone turn its shape into a burst', function () {
|
|
$page = ready(visit('/upload'));
|
|
|
|
$burst = "getComputedStyle(document.querySelector('[data-test=drop-zone-burst]')).opacity";
|
|
|
|
$page->assertScript("{$burst} === '0'");
|
|
|
|
$page->script("window.eval(\"document.querySelector('[data-test=drop-zone]').dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true }))\")");
|
|
|
|
$page->assertScript("{$burst} === '1'")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
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]],
|
|
[],
|
|
);
|
|
|
|
$page = ready(visit(route('share.created', $share, false)));
|
|
|
|
$page->assertSee('Share Created!')
|
|
->assertScript("document.querySelector('[data-test=\"share-link\"]').value.includes('/s/')");
|
|
|
|
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
|
|
|
|
$page->click('[data-md-field-copy]')
|
|
->assertScript("typeof window.copied === 'string' && window.copied.includes('/s/')")
|
|
->assertSee('Copied to the clipboard');
|
|
});
|
|
|
|
test('an uploader generates a share password and copies it from the upload page', function () {
|
|
$page = ready(visit('/upload'));
|
|
|
|
$page->click('label:has-text("Password protect")')
|
|
->click('[data-test="generate-password"]')
|
|
->assertScript("/^[A-Za-z0-9]{20}$/.test(document.querySelector('input[wire\\\\:model=\"password\"]').value)");
|
|
|
|
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
|
|
|
|
$page->click('[data-test="copy-password"]')
|
|
->assertScript("window.copied === document.querySelector('input[wire\\\\:model=\"password\"]').value")
|
|
->assertSee('Copied to the clipboard')
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
test('the new share\'s password stays masked on the page and is copied without being shown', function () {
|
|
$share = app(ShareService::class)->createShare(
|
|
[['file' => UploadedFile::fake()->create('contract.pdf', 80), 'relativePath' => null]],
|
|
['password' => 'violet-orbit-canyon'],
|
|
);
|
|
$this->withSession(['share_password' => ['token' => $share->token, 'password' => Crypt::encryptString('violet-orbit-canyon')]]);
|
|
|
|
$page = ready(visit(route('share.created', $share, false)));
|
|
|
|
$field = "document.querySelector('[data-test=share-password]')";
|
|
$page->assertScript("{$field}.type === 'password'")
|
|
->assertScript("{$field}.value === 'violet-orbit-canyon'");
|
|
|
|
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
|
|
|
|
$page->click('[data-md-field]:has([data-test="share-password"]) [data-md-field-copy]')
|
|
->assertScript("window.copied === 'violet-orbit-canyon'")
|
|
->assertScript("{$field}.type === 'password'")
|
|
->assertSee('Copied to the clipboard')
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
test('a new share\'s QR code opens in a dialog and saves as a PNG', function () {
|
|
$share = Share::factory()->withPassword()->create();
|
|
|
|
$page = ready(visit(route('share.created', $share, false)));
|
|
|
|
$page->click('[data-test="show-qr-code"]')
|
|
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open")
|
|
// The white field and quiet zone are baked into the SVG itself (App\Services\QrCodeService),
|
|
// not a background colour on its container, so a scanner keeps its contrast in dark mode too.
|
|
->assertScript("document.querySelector('[data-qr-code] svg rect').getAttribute('fill') === '#ffffff'")
|
|
->assertScript("document.querySelector('[data-qr-code] svg').getBoundingClientRect().width > 200")
|
|
->assertSee('Recipients also need the password.');
|
|
|
|
// Record what would be saved instead of saving it.
|
|
$page->script('window.eval("URL.revokeObjectURL = () => {}; HTMLAnchorElement.prototype.click = function () { window.saved = { name: this.download, href: this.href } }")');
|
|
|
|
$page->click('[data-test="download-qr-code"]')
|
|
->assertScript("window.eval('window.saved?.name') === 'share-{$share->token}.png'");
|
|
|
|
// A QR code is roughly a third to a half dark; a blank or failed drawing is not.
|
|
$page->script("window.eval(\"(async () => { const blob = await (await fetch(window.saved.href)).blob(); const bitmap = await createImageBitmap(blob); const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); const pixels = context.getImageData(0, 0, bitmap.width, bitmap.height).data; let dark = 0; for (let i = 0; i < pixels.length; i += 4) { if (pixels[i] < 128) { dark++ } } window.png = { type: blob.type, width: bitmap.width, dark: dark / (pixels.length / 4) } })()\")");
|
|
|
|
$page->assertScript("window.eval('window.png?.type') === 'image/png'")
|
|
->assertScript("window.eval('window.png.width') === 1024")
|
|
->assertScript("window.eval('window.png.dark') > 0.2 && window.eval('window.png.dark') < 0.6")
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
test('the share sheet gets the link, and says so only when it fails for another reason than a cancel', function () {
|
|
$share = Share::factory()->create();
|
|
$actions = "Alpine.\$data(document.querySelector('[data-test=share-actions]'))";
|
|
|
|
$page = ready(visit(route('share.created', $share, false)));
|
|
|
|
// Shown only where the browser has a share sheet.
|
|
$page->assertScript("window.eval(\"getComputedStyle(document.querySelector('[data-test=share-sheet]').parentElement).display === 'none'\") === (typeof navigator.share !== 'function')");
|
|
|
|
$page->script("window.eval(\"navigator.share = async (data) => { window.shared = data }; {$actions}.share()\")");
|
|
$page->assertScript("window.eval('window.shared?.url') === '".route('share.download', $share)."'");
|
|
|
|
$page->script("window.eval(\"navigator.share = async () => { throw new DOMException('Cancelled', 'AbortError') }; {$actions}.share()\")");
|
|
$page->wait(0.3)->assertDontSee('The share sheet could not open.');
|
|
|
|
$page->script("window.eval(\"navigator.share = async () => { throw new DOMException('Not allowed', 'NotAllowedError') }; {$actions}.share()\")");
|
|
$page->assertSee('The share sheet could not open.');
|
|
});
|
|
|
|
test('a recipient on a phone unlocks a password-protected share and sees its files', function () {
|
|
$share = app(ShareService::class)->createShare(
|
|
[['file' => UploadedFile::fake()->create('holiday-photos.zip', 120), 'relativePath' => null]],
|
|
['password' => 'correct horse'],
|
|
);
|
|
|
|
$page = ready(visit(route('share.download', $share, false))->resize(393, 852));
|
|
|
|
$page->assertSee('Password Required')
|
|
->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
|
|
->assertNoJavaScriptErrors()
|
|
->type('input[type="password"]', 'correct horse')
|
|
->press('Unlock')
|
|
->assertSee('Shared Files')
|
|
->assertSee('holiday-photos.zip')
|
|
->assertScript("document.querySelectorAll('[popover]').length === 0")
|
|
->assertScript('document.documentElement.scrollWidth <= window.innerWidth');
|
|
});
|
|
|
|
test('pressing a download on a limited share turns the note about remaining downloads into the recipient\'s hour', function () {
|
|
$share = app(ShareService::class)->createShare(
|
|
[['file' => UploadedFile::fake()->createWithContent('report.pdf', 'report'), 'relativePath' => null]],
|
|
['max_downloads' => 3],
|
|
);
|
|
|
|
$page = ready(visit(route('share.download', $share, false)));
|
|
|
|
$page->assertSee('Downloading uses 1 of 3 remaining downloads.')
|
|
->assertDontSee('You have 1 hour to download the files.');
|
|
|
|
// The press is what the page reacts to; the download itself stays out of the browser.
|
|
$page->script("window.eval(\"document.addEventListener('click', (event) => event.preventDefault(), true); document.querySelector('[aria-label=\\\"Download report.pdf\\\"]').click()\")");
|
|
|
|
$page->assertSee('You have 1 hour to download the files.')
|
|
->assertDontSee('Downloading uses 1 of 3 remaining downloads.')
|
|
->assertNoJavaScriptErrors();
|
|
});
|
|
|
|
test('an admin sorts the shares list and deletes a share through its dialog', function () {
|
|
$admin = User::factory()->admin()->create();
|
|
Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 1, 'created_at' => now()->subDay()]);
|
|
$doomed = Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 9, 'created_at' => now()->subDays(2)]);
|
|
|
|
$this->actingAs($admin);
|
|
|
|
$page = ready(visit('/admin/dashboard'));
|
|
|
|
$page->assertScript("document.querySelector('[data-test=\"share-row\"] code').textContent.trim() === 'aaaaaaaaaaaaaaaa'")
|
|
->select('[data-test="shares-sort"]', 'most-downloaded')
|
|
->wait(0.5)
|
|
->assertScript("document.querySelector('[data-test=\"share-row\"] code').textContent.trim() === 'zzzzzzzzzzzzzzzz'");
|
|
|
|
$page->click("[data-test=\"delete-share-{$doomed->id}\"]")
|
|
->assertScript("[...document.querySelectorAll('dialog')].some((dialog) => dialog.open)")
|
|
->click('[data-test="confirm-delete-share"]')
|
|
->assertScript("! [...document.querySelectorAll('dialog')].some((dialog) => dialog.open)")
|
|
->assertDontSee('zzzzzzzzzzzzzzzz');
|
|
|
|
expect(Share::query()->find($doomed->id))->toBeNull();
|
|
});
|
|
|
|
test('a first visit follows the system theme, and Appearance switches it', function () {
|
|
ready(visit('/upload')->inDarkMode())
|
|
->assertScript("document.documentElement.dataset.theme === 'dark'")
|
|
->assertScript("document.documentElement.dataset.themeChoice === 'system'");
|
|
|
|
$this->actingAs(User::factory()->create());
|
|
|
|
$page = ready(visit('/settings/appearance')->inDarkMode());
|
|
|
|
$page->click('label:has(input[name="material-theme"][value="light"])')
|
|
->assertScript("document.documentElement.dataset.theme === 'light'")
|
|
->assertScript("localStorage.getItem('sealshare-theme') === 'light'");
|
|
});
|
|
|
|
test('an admin previews a colour profile, saves it, and every page wears it', function () {
|
|
$this->actingAs(User::factory()->admin()->create());
|
|
|
|
$page = ready(visit('/admin/settings'));
|
|
|
|
$page->assertScript("document.documentElement.getAttribute('data-scheme') === 'indigo'")
|
|
->click('[data-test="color-profile"] [data-md-scheme-picker-option="teal"]')
|
|
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'");
|
|
|
|
expect(Setting::get('color_profile'))->toBeNull();
|
|
|
|
$page->click('[data-test="save-settings"]')
|
|
->assertSee('Settings saved successfully.');
|
|
|
|
expect(Setting::get('color_profile'))->toBe('teal');
|
|
|
|
ready(visit('/upload'))
|
|
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'")
|
|
->assertScript("getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-primary').trim() === '".Scheme::profiles()['teal']['light']['primary']."'")
|
|
->assertNoJavaScriptErrors();
|
|
});
|