isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit(), 404); $share->load('files'); $key = $this->resolveDecryptionKey($share); return new StreamedResponse(function () use ($share, $key): void { $zip = new ZipStream( defaultCompressionMethod: CompressionMethod::STORE, defaultEnableZeroHeader: true, sendHttpHeaders: false, flushOutput: true, ); foreach ($share->files as $file) { $chunks = $this->encryptionService->decryptedChunks( Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path)), $key, ); $zip->addFileFromPsr7Stream(fileName: $this->archiveName($file), stream: 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; })); } $zip->finish(); $this->shareService->recordDownload($share); }, 200, [ 'Content-Type' => 'application/zip', 'Content-Disposition' => HeaderUtils::makeDisposition('attachment', 'share-'.$share->token.'.zip'), ]); } /** * Download a single file. */ public function downloadFile(Share $share, ShareFile $shareFile): StreamedResponse { abort_if(! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit(), 404); abort_if($shareFile->share_id !== $share->id, 404); $key = $this->resolveDecryptionKey($share); $encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($shareFile->stored_path)); $mimeType = $shareFile->mime_type ?? 'application/octet-stream'; $headers = [ 'Content-Type' => $mimeType, 'Content-Disposition' => HeaderUtils::makeDisposition( 'attachment', $shareFile->original_name, 'download', ), ]; if ($shareFile->file_size !== null) { $headers['Content-Length'] = $shareFile->file_size; } return new StreamedResponse(function () use ($encryptedPath, $key, $share): void { $this->encryptionService->streamDecryptedFile($encryptedPath, $key); $this->shareService->recordDownload($share); }, 200, $headers); } /** * 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; } /** * Resolve the decryption key from session or share. */ private function resolveDecryptionKey(Share $share): string { if ($share->isPasswordProtected()) { $key = session('share_key_'.$share->token); abort_if(! $key, 403, 'Password required'); return $key; } return $this->shareService->getDecryptionKey($share); } }