isCompleted() || $share->isExpired(), 404); $share->load(['files' => fn ($query) => $query->where('is_text', false)]); abort_if($share->files->isEmpty(), 404); $key = $this->shareService->sessionDecryptionKey($share, $request->session()); // Counted before the body streams: the session is saved by then. abort_unless($this->shareService->claimDownload($share, $request->session()), 404); $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), ); } $size = $zip->finish(); return new StreamedResponse(function () use ($zip): void { // A large share takes longer than any request limit to send. set_time_limit(0); $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. 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 { abort_if(! $share->isCompleted() || $share->isExpired(), 404); abort_if($shareFile->share_id !== $share->id || $shareFile->is_text, 404); $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)); $partial = $start > 0 || $end < $size - 1; $headers = [ '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 ($partial) { $headers['Content-Range'] = "bytes {$start}-{$end}/{$size}"; } 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); } }, $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; }); } /** * A file's path inside the archive: its folder path when it came from a dropped folder, never * one that could reach outside the archive. */ private function archiveName(ShareFile $file): string { $filename = str_replace('\\', '/', $file->relative_path ?: $file->original_name); if (str_starts_with($filename, '/') || str_contains($filename, '..')) { return basename($filename); } return $filename; } }