Files
SealShare/tests/Screenshots/ScreenshotsTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 40e35bab0e 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>
2026-09-16 20:49:17 +02:00

157 lines
7.6 KiB
PHP

<?php
use App\Models\Share;
use App\Services\QrCodeService;
use App\Services\ShareService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Tests\Screenshots\DemoData;
use Tests\Screenshots\Publisher;
/*
* The screenshots on the website and in the README, from fixed demo data with the clock frozen.
* Not part of any test suite: `composer screenshots` runs this directory (Chromium), and each capture
* is published to website/img/screenshots as soon as it is taken.
*/
beforeEach(function () {
// Sessions have to outlive a request: a sign-in, an unlocked share.
config(['session.driver' => 'file']);
Storage::fake('shares');
$this->travelTo(Carbon::parse('2026-10-01 09:30'));
DemoData::shares();
});
/**
* A visited page on a device in a theme, once it can be used. Tests call `visit()` themselves, on a
* line of its own: Pest starts its browser only for tests under tests/Browser or whose body calls
* `visit(` after whitespace.
*/
function shotPage(mixed $visit, string $device, string $theme): mixed
{
$pending = $device === 'desktop' ? $visit->on()->macbook14() : $visit->on()->iPhone15Pro();
$page = $theme === 'dark' ? $pending->inDarkMode() : $pending->inLightMode();
return $page->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
/**
* Capture the viewport once fonts are in and every finite animation has run, then publish it.
*
* The in-process server listens on 127.0.0.1 at a random port, so links would read differently on
* every run: the page is shown as it reads on an installation at https://files.example.com, the
* server's origin replaced in text and fields, and the QR code drawn for that address.
*/
function shoot(mixed $page, string $device, string $theme, string $name): void
{
$origin = 'https://files.example.com';
$qrCode = app(QrCodeService::class)->svg($origin.'/s/'.DemoData::DELIVERY_TOKEN);
$page->script("document.head.insertAdjacentHTML('beforeend', '<style>*{caret-color:transparent!important}</style>')");
$page->script('(() => { const from = location.origin, to = '.json_encode($origin).'; document.querySelectorAll("input").forEach((input) => { input.value = input.value.replaceAll(from, to) }); const text = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); while (text.nextNode()) { text.currentNode.nodeValue = text.currentNode.nodeValue.replaceAll(from, to) } document.querySelectorAll("[data-qr-code]").forEach((panel) => { panel.innerHTML = '.json_encode($qrCode).' }) })()');
// Counting figures run on requestAnimationFrame rather than as Web Animations.
$page->wait(1.2)
->assertScript("document.fonts.status === 'loaded' && document.getAnimations().every((animation) => animation.playState !== 'running' || animation.effect?.getComputedTiming().iterations === Infinity)");
$capture = "{$device}-{$theme}-{$name}";
$page->screenshot(fullPage: false, filename: $capture);
Publisher::forProject()->publish($capture, $device, $theme, $name, $device === 'desktop' ? [1600, 800] : [1080, 540]);
}
/**
* 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
{
$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"))';
$page->script('(async () => { await '.$wire.'.registerFiles('.json_encode($selection).') })()');
$page->waitForText('Selected Files ('.count($files).')');
$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));
}
}
$page->script('(async () => { await '.$wire.'.$refresh() })()');
$page->assertSee('Selected Files ('.count($files).')');
}
$files = ['Q3 Report.pdf' => 2400, 'Contract 2026.pdf' => 380, 'Product photos/hero-shot.jpg' => 4800, 'Product photos/detail.jpg' => 3900];
test('desktop', function (string $theme) use ($files) {
$upload = visit('/upload');
$page = shotPage($upload, 'desktop', $theme);
selectFiles($page, $files);
$page->click('label:has-text("Password protect")')
->type('input[wire\:model="password"]', DemoData::PROTECTED_PASSWORD)
->type('input[wire\:model="maxDownloads"]', '5');
// The drop zone, the files and the options fill the window; typing left the page wherever it scrolled.
$page->script("document.activeElement?.blur(); window.scrollTo(0, document.querySelector('[data-test=drop-zone]').getBoundingClientRect().top + window.scrollY - 24)");
shoot($page, 'desktop', $theme, '01-upload');
$created = visit(route('share.created', DemoData::DELIVERY_TOKEN, false));
$page = shotPage($created, 'desktop', $theme);
shoot($page, 'desktop', $theme, '02-share-created');
$page->click('[data-test="show-qr-code"]')
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
shoot($page, 'desktop', $theme, '03-qr-code');
$download = visit(route('share.download', DemoData::DELIVERY_TOKEN, false));
shoot(shotPage($download, 'desktop', $theme), 'desktop', $theme, '04-download');
$this->actingAs(DemoData::admin());
$dashboard = visit('/admin/dashboard');
shoot(shotPage($dashboard, 'desktop', $theme), 'desktop', $theme, '05-admin-dashboard');
// Branding typed in but not saved, so the other shots keep SealShare's own.
$settings = visit('/admin/settings');
$page = shotPage($settings, 'desktop', $theme)
->type('input[wire\\:model="siteTitle"]', 'Acme Files')
->type('textarea[wire\\:model="siteDescription"]', 'Send and receive files securely with Acme Engineering.');
$page->script('document.activeElement?.blur(); window.scrollTo(0, 0)');
shoot($page, 'desktop', $theme, '06-admin-settings');
})->with(['light', 'dark']);
test('phone', function (string $theme) use ($files) {
$upload = visit('/upload');
$page = shotPage($upload, 'phone', $theme);
selectFiles($page, $files);
shoot($page, 'phone', $theme, '01-upload');
$password = visit(route('share.download', DemoData::PROTECTED_TOKEN, false));
shoot(shotPage($password, 'phone', $theme), 'phone', $theme, '02-password');
$download = visit(route('share.download', DemoData::DELIVERY_TOKEN, false));
shoot(shotPage($download, 'phone', $theme), 'phone', $theme, '03-download');
$created = visit(route('share.created', DemoData::DELIVERY_TOKEN, false));
$page = shotPage($created, 'phone', $theme);
$page->click('[data-test="show-qr-code"]')
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open");
shoot($page, 'phone', $theme, '04-qr-code');
})->with(['light', 'dark']);