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
+104 -36
View File
@@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\StreamedResponse;
use ZipStream\CompressionMethod;
use ZipStream\OperationMode;
use ZipStream\ZipStream;
class DownloadController extends Controller
@@ -24,7 +25,9 @@ class DownloadController extends Controller
/**
* Download all files as a ZIP archive, streamed file by file as it is decrypted: stored without
* compression, with ZIP64 for files over 4 GB, and never held in memory or written to disk. The
* private text is left out: it is only ever shown on the page.
* archive's size is worked out from the files' sizes first, so the browser shows its progress
* and notices a download cut short. The private text is left out: it is only ever shown on the
* page.
*/
public function download(Request $request, Share $share): StreamedResponse
{
@@ -38,45 +41,40 @@ class DownloadController extends Controller
// Counted before the body streams: the session is saved by then.
abort_unless($this->shareService->claimDownload($share, $request->session()), 404);
return new StreamedResponse(function () use ($share, $key): void {
$zip = new ZipStream(
defaultCompressionMethod: CompressionMethod::STORE,
defaultEnableZeroHeader: true,
sendHttpHeaders: false,
flushOutput: true,
$zip = new ZipStream(
operationMode: OperationMode::SIMULATE_STRICT,
defaultCompressionMethod: CompressionMethod::STORE,
defaultEnableZeroHeader: true,
sendHttpHeaders: false,
flushOutput: true,
);
foreach ($share->files as $file) {
$zip->addFileFromCallback(
fileName: $this->archiveName($file),
exactSize: $file->file_size,
callback: fn (): PumpStream => $this->decryptedStream($share, $file, $key),
);
}
foreach ($share->files as $file) {
$chunks = $this->encryptionService->decryptedChunks(
Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path)),
$key,
);
$size = $zip->finish();
$zip->addFileFromPsr7Stream(fileName: $this->archiveName($file), stream: new PumpStream(function () use ($chunks): string|false {
while ($chunks->valid() && $chunks->current() === '') {
$chunks->next();
}
return new StreamedResponse(function () use ($zip): void {
// A large share takes longer than any request limit to send.
set_time_limit(0);
if (! $chunks->valid()) {
return false;
}
$chunk = $chunks->current();
$chunks->next();
return $chunk;
}));
}
$zip->finish();
$zip->executeSimulation();
}, 200, [
'Content-Type' => 'application/zip',
'Content-Length' => $size,
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', 'share-'.$share->token.'.zip'),
]);
}
/**
* Download a single file; never the private text, which is only ever shown on the page.
* Download a single file; never the private text, which is only ever shown on the page. A
* `Range` request gets the bytes from where an interrupted download stopped, so the browser,
* or the page's folder download, resumes it instead of starting over.
*/
public function downloadFile(Request $request, Share $share, ShareFile $shareFile): StreamedResponse
{
@@ -85,30 +83,100 @@ class DownloadController extends Controller
$key = $this->shareService->sessionDecryptionKey($share, $request->session());
$size = $shareFile->file_size;
$etag = '"'.hash('xxh128', $shareFile->stored_path).'"';
[$start, $end] = $this->requestedRange($request, $size, $etag) ?? [0, $size - 1];
abort_unless($this->shareService->claimDownload($share, $request->session()), 404);
$encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($shareFile->stored_path));
$mimeType = $shareFile->mime_type ?? 'application/octet-stream';
$partial = $start > 0 || $end < $size - 1;
$headers = [
'Content-Type' => $mimeType,
'Content-Type' => $shareFile->mime_type ?? 'application/octet-stream',
'Content-Disposition' => HeaderUtils::makeDisposition(
'attachment',
$shareFile->original_name,
'download',
),
'Content-Length' => max($end - $start + 1, 0),
'Accept-Ranges' => 'bytes',
'ETag' => $etag,
];
if ($shareFile->file_size !== null) {
$headers['Content-Length'] = $shareFile->file_size;
if ($partial) {
$headers['Content-Range'] = "bytes {$start}-{$end}/{$size}";
}
return new StreamedResponse(function () use ($encryptedPath, $key): void {
foreach ($this->encryptionService->decryptedChunks($encryptedPath, $key) as $chunk) {
return new StreamedResponse(function () use ($encryptedPath, $key, $start, $end): void {
set_time_limit(0);
$remaining = $end - $start + 1;
foreach ($this->encryptionService->decryptedChunks($encryptedPath, $key, $start) as $chunk) {
if (strlen($chunk) >= $remaining) {
echo substr($chunk, 0, $remaining);
flush();
return;
}
echo $chunk;
flush();
$remaining -= strlen($chunk);
}
}, 200, $headers);
}, $partial ? 206 : 200, $headers);
}
/**
* The first and last byte a single `Range` request asks for, or null to send the whole file:
* without a range, with one this does not read (several ranges, another unit), or with an
* `If-Range` naming another version of the file. A range wholly past the end is refused.
*
* @return array{0: int, 1: int}|null
*/
private function requestedRange(Request $request, int $size, string $etag): ?array
{
$range = (string) $request->header('Range');
if ($size === 0 || ! preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches) || $matches[1].$matches[2] === ''
|| ($request->hasHeader('If-Range') && $request->header('If-Range') !== $etag)) {
return null;
}
[$start, $end] = $matches[1] === ''
? [max($size - (int) $matches[2], 0), $size - 1]
: [(int) $matches[1], $matches[2] === '' ? $size - 1 : min((int) $matches[2], $size - 1)];
abort_if($start > $end, 416, headers: ['Content-Range' => "bytes */{$size}"]);
return [$start, $end];
}
/**
* A file's decrypted content as a stream ZipStream reads from.
*/
private function decryptedStream(Share $share, ShareFile $file, string $key): PumpStream
{
$chunks = $this->encryptionService->decryptedChunks(
Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path)),
$key,
);
return new PumpStream(function () use ($chunks): string|false {
while ($chunks->valid() && $chunks->current() === '') {
$chunks->next();
}
if (! $chunks->valid()) {
return false;
}
$chunk = $chunks->current();
$chunks->next();
return $chunk;
});
}
/**
+8 -1
View File
@@ -85,9 +85,16 @@ class ShareDownload extends Component
public function render(): mixed
{
$shareService = app(ShareService::class);
$files = $this->share->files->where('is_text', false)->values();
return view('livewire.share-download', [
'files' => $this->share->files->where('is_text', false)->values(),
'files' => $files,
'downloads' => $files->map(fn ($file): array => [
'id' => $file->id,
'path' => $file->relative_path ?: $file->original_name,
'size' => $file->file_size,
'url' => route('share.download.file', [$this->share, $file]),
]),
'hasText' => $this->share->files->contains('is_text', true),
'downloadWindowEndsAt' => $shareService->downloadWindowEndsAt($this->share, session()->driver()),
'remainingDownloads' => $this->share->max_downloads ? max($this->share->max_downloads - $this->share->download_count, 0) : null,
+32 -11
View File
@@ -200,20 +200,35 @@ class FileEncryptionService
}
/**
* The decrypted content of a file in any of the three formats, chunk by chunk.
* The decrypted content of a file in any of the three formats, chunk by chunk, from byte
* `$offset` of the plaintext on. A `SEALCHK2` file starts at the chunk holding that byte; the
* older formats are decrypted from their start and the bytes before it dropped.
*
* @return Generator<int, string>
*/
public function decryptedChunks(string $encryptedPath, string $key): Generator
public function decryptedChunks(string $encryptedPath, string $key, int $offset = 0): Generator
{
$magic = (string) file_get_contents($encryptedPath, false, null, 0, 8);
if ($magic === self::MAGIC) {
yield from $this->decryptChunks($encryptedPath, $key);
} elseif ($magic === self::LEGACY_CHUNKED_MAGIC) {
yield from $this->decryptLegacyChunks($encryptedPath, $key);
} else {
yield $this->decryptLegacy($encryptedPath, $key);
yield from $this->decryptChunks($encryptedPath, $key, $offset);
return;
}
$chunks = $magic === self::LEGACY_CHUNKED_MAGIC
? $this->decryptLegacyChunks($encryptedPath, $key)
: [$this->decryptLegacy($encryptedPath, $key)];
foreach ($chunks as $chunk) {
if ($offset >= strlen($chunk)) {
$offset -= strlen($chunk);
continue;
}
yield substr($chunk, $offset);
$offset = 0;
}
}
@@ -251,7 +266,7 @@ class FileEncryptionService
*
* @return Generator<int, string>
*/
private function decryptChunks(string $encryptedPath, string $key): Generator
private function decryptChunks(string $encryptedPath, string $key, int $offset = 0): Generator
{
$handle = fopen($encryptedPath, 'rb');
@@ -270,10 +285,16 @@ class FileEncryptionService
throw new RuntimeException('Invalid encrypted file: no chunks');
}
for ($index = 0; $index < $chunkCount; $index++) {
$chunk = (string) fread($handle, $storedChunkSize);
$firstIndex = min(intdiv($offset, max($chunkSize, 1)), $chunkCount - 1);
$skip = $offset - $firstIndex * $chunkSize;
fseek($handle, $this->chunkOffset($firstIndex, $chunkSize));
yield $this->decryptChunk($chunk, $key, $noncePrefix, $index, $index === $chunkCount - 1);
for ($index = $firstIndex; $index < $chunkCount; $index++) {
$chunk = (string) fread($handle, $storedChunkSize);
$plaintext = $this->decryptChunk($chunk, $key, $noncePrefix, $index, $index === $chunkCount - 1);
yield $skip > 0 ? substr($plaintext, $skip) : $plaintext;
$skip = 0;
}
} finally {
fclose($handle);
+7 -3
View File
@@ -393,8 +393,10 @@ class ShareService
/**
* When this session's download window for a share ends, or null while it has none open. The
* window opens with the session's counted download; until it ends, the session may start more
* downloads of the share without counting them, even once the share has reached its limit.
* window opens with the session's counted download and each download started in it keeps it
* open, so downloading a large share file by file never outlasts it; until it ends, the session
* may start more downloads of the share without counting them, even once the share has
* reached its limit.
*/
public function downloadWindowEndsAt(Share $share, Session $session): ?CarbonInterface
{
@@ -418,6 +420,8 @@ class ShareService
public function claimDownload(Share $share, Session $session): bool
{
if ($this->downloadWindowEndsAt($share, $session) !== null) {
$session->put($this->downloadSessionKey($share), now()->getTimestamp());
return true;
}
@@ -436,7 +440,7 @@ class ShareService
}
/**
* The session key holding when this session's download of a share was counted.
* The session key holding when this session last started a download of a share.
*/
private function downloadSessionKey(Share $share): string
{