Download large shares file by file, resumably

A 29 GB share with 254 files failed as one ZIP: the single request ran
into the 300-second request limit and, behind a proxy we cannot
configure, could be cut at any time with no way to resume.

Single-file downloads now answer HTTP Range requests (206, ETag and
If-Range, 416 past the end): a SEALCHK2 file is decrypted from the chunk
holding the first byte asked for. On that, the download page's new
"Download all to a folder" (File System Access API, Chrome and Edge on a
computer) saves every file into a folder the recipient picks, one
request per file, in its folder path; a broken connection is retried
from the byte on disk, complete files are skipped, and failed files get
a Retry button. Other browsers keep the ZIP and the per-file buttons.

The ZIP is sized up front with ZipStream's strict simulation and sent
with Content-Length, so the browser shows progress and notices a cut.
Both download routes lift the time limit for their streams.

The recipient's download window now restarts with each download, so a
long file-by-file download never outlasts it. The download page shows
the file count and total size and marks the files this browser has
downloaded, and the upload page gains "Browse Folder", which keeps each
file's path as a dropped folder does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-10-08 21:20:35 +02:00
co-authored by Claude Opus 5.5
parent f9a7839ad3
commit b594fb2300
11 changed files with 589 additions and 62 deletions
+55
View File
@@ -242,3 +242,58 @@ test('the admin settings page has six sections, each a card headed by an h2', fu
expect($result['cardCount'])->toBe(6);
expect($result['everyCardHeaded'])->toBeTrue();
});
test('download all to a folder writes every file in its folder path, resumes a partial file and marks them downloaded', function () {
config(['uploads.chunk_size' => 1000]);
$binary = random_bytes(2500);
$share = app(ShareService::class)->createShare([
['file' => UploadedFile::fake()->createWithContent('notes.txt', 'hello folder download'), 'relativePath' => null],
['file' => UploadedFile::fake()->createWithContent('photo.bin', $binary), 'relativePath' => 'holiday/photo.bin'],
]);
$page = ready(visit(route('share.download', $share, false)));
// The browser's private file system stands in for the folder the recipient would pick; notes.txt
// is already half there, as a stopped run leaves it.
$page->script("(async () => {
const root = await navigator.storage.getDirectory();
for await (const name of root.keys()) { await root.removeEntry(name, { recursive: true }); }
const partial = await (await root.getFileHandle('notes.txt', { create: true })).createWritable();
await partial.write('hello ');
await partial.close();
window.showDirectoryPicker = () => Promise.resolve(root);
Alpine.\$data(document.querySelector('[data-test=download-to-folder]')).supported = true;
})()");
$page->click('[data-test="download-to-folder"]');
$page->assertScript("Alpine.\$data(document.querySelector('[data-test=folder-download]')).finished === true");
$files = $page->script("(async () => {
const root = await navigator.storage.getDirectory();
const notes = await (await root.getFileHandle('notes.txt')).getFile();
const photo = await (await (await root.getDirectoryHandle('holiday')).getFileHandle('photo.bin')).getFile();
const bytes = new Uint8Array(await photo.arrayBuffer());
return { notes: await notes.text(), photo: btoa(String.fromCharCode(...bytes)) };
})()");
expect($files['notes'])->toBe('hello folder download');
expect(base64_decode($files['photo']))->toBe($binary);
$page->assertScript("[...document.querySelectorAll('[data-test=file-downloaded]')].filter((mark) => mark.offsetParent !== null).length === 2")
->assertNoJavaScriptErrors();
});
test('Browse Folder picks a whole folder and keeps each file\'s path in it', function () {
$page = ready(visit('/upload'));
$page->assertScript("document.querySelector('[data-test=folder-input]').webkitdirectory === true");
// A folder picker's files, as the browser hands them over: each with its path from the folder.
$page->script("(() => {
const file = new File(['inside the folder'], 'notes.txt', { type: 'text/plain' });
Object.defineProperty(file, 'webkitRelativePath', { value: 'holiday/docs/notes.txt' });
Alpine.\$data(document.querySelector('[data-test=drop-zone]')).choose({ target: { files: [file], value: '' } });
})()");
$page->assertSee('holiday/docs/notes.txt')
->assertNoJavaScriptErrors();
});
+71 -2
View File
@@ -18,6 +18,15 @@ test('share download page renders for valid share', function () {
$response->assertOk();
});
test('share download page says how many files the share holds and their size', function () {
Storage::fake('shares');
$share = createShareWithFiles();
$response = $this->get(route('share.download', $share));
$response->assertSee('3 files, 31 B');
});
test('share download page returns 404 for expired share', function () {
$share = Share::factory()->expired()->create();
@@ -100,7 +109,10 @@ test('zip download streams a valid archive with every file\'s original content',
]);
$zipPath = tempnam(sys_get_temp_dir(), 'zip');
file_put_contents($zipPath, $this->get(route('share.download.all', $share))->streamedContent());
$response = $this->get(route('share.download.all', $share));
file_put_contents($zipPath, $content = $response->streamedContent());
$response->assertHeader('Content-Length', strlen($content));
$zip = new ZipArchive;
expect($zip->open($zipPath))->toBeTrue();
@@ -111,6 +123,46 @@ test('zip download streams a valid archive with every file\'s original content',
unlink($zipPath);
});
test('a range request gets the file from that byte on, decrypted across chunks', function (string $range, int $start, int $end) {
Storage::fake('shares');
config(['uploads.chunk_size' => 1000]);
$binary = random_bytes(2500);
$share = app(ShareService::class)->createShare([
['file' => UploadedFile::fake()->createWithContent('photo.bin', $binary), 'relativePath' => null],
]);
$response = $this->get(route('share.download.file', [$share, $share->files->first()]), ['Range' => $range]);
$response->assertStatus(206)
->assertHeader('Content-Range', "bytes {$start}-{$end}/2500")
->assertHeader('Content-Length', $end - $start + 1);
expect($response->streamedContent())->toBe(substr($binary, $start, $end - $start + 1));
})->with([
'to the end' => ['bytes=1500-', 1500, 2499],
'across a chunk boundary' => ['bytes=999-1000', 999, 1000],
'the last bytes' => ['bytes=-10', 2490, 2499],
]);
test('a range request for another version of the file gets the whole file', function () {
Storage::fake('shares');
$share = createShareWithFile(content: 'whole file');
$response = $this->get(route('share.download.file', [$share, $share->files->first()]), ['Range' => 'bytes=6-', 'If-Range' => '"other"']);
$response->assertOk()->assertHeader('Accept-Ranges', 'bytes');
expect($response->streamedContent())->toBe('whole file');
});
test('a range past the end of the file is refused and uses no download', function () {
Storage::fake('shares');
$share = createShareWithFile(content: 'short');
$response = $this->get(route('share.download.file', [$share, $share->files->first()]), ['Range' => 'bytes=5-']);
$response->assertStatus(416)->assertHeader('Content-Range', 'bytes */5');
expect($share->fresh()->download_count)->toBe(0);
});
test('the last download streams and the share stays for the cleanup, with the time of that download', function () {
Storage::fake('shares');
$this->freezeSecond();
@@ -170,6 +222,20 @@ test('the recipient who took the last download can no longer open the share an h
'download one file' => 'share.download.file',
]);
test('each download keeps the recipient\'s window open', function () {
Storage::fake('shares');
$share = createShareWithFiles(maxDownloads: 1);
$this->get(route('share.download.file', [$share, $share->files->first()]));
$this->travel(50)->minutes();
$this->get(route('share.download.file', [$share, $share->files->first()]));
$this->travel(50)->minutes();
$response = $this->get(route('share.download.file', [$share, $share->files->last()]));
$response->assertOk();
expect($share->fresh()->download_count)->toBe(1);
});
test('a recipient whose window has ended uses another download when one is left', function () {
Storage::fake('shares');
$share = createShareWithFiles(maxDownloads: 2);
@@ -368,7 +434,10 @@ test('the zip of a mixed share has only the files, not the private text', functi
);
$zipPath = tempnam(sys_get_temp_dir(), 'zip');
file_put_contents($zipPath, $this->get(route('share.download.all', $share))->streamedContent());
$response = $this->get(route('share.download.all', $share));
file_put_contents($zipPath, $content = $response->streamedContent());
$response->assertHeader('Content-Length', strlen($content));
$zip = new ZipArchive;
expect($zip->open($zipPath))->toBeTrue();