diff --git a/app/Http/Controllers/DownloadController.php b/app/Http/Controllers/DownloadController.php index 5a1f897..45fefd8 100644 --- a/app/Http/Controllers/DownloadController.php +++ b/app/Http/Controllers/DownloadController.php @@ -7,8 +7,9 @@ use App\Models\ShareFile; use App\Services\FileEncryptionService; use App\Services\ShareService; use Illuminate\Support\Facades\Storage; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\StreamedResponse; -use ZipStream\ZipStream; +use ZipArchive; class DownloadController extends Controller { @@ -20,40 +21,39 @@ class DownloadController extends Controller /** * Download all files as a ZIP archive. */ - public function download(Share $share): StreamedResponse + public function download(Share $share): BinaryFileResponse { abort_if($share->isExpired() || $share->hasReachedDownloadLimit(), 404); $share->load('files'); $key = $this->resolveDecryptionKey($share); - $this->shareService->recordDownload($share); + $tempPath = tempnam(sys_get_temp_dir(), 'sealshare_'); - return new StreamedResponse(function () use ($share, $key): void { - $zip = new ZipStream( - outputName: 'share-'.$share->token.'.zip', - sendHttpHeaders: false, - ); + $zip = new ZipArchive; + $zip->open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); - foreach ($share->files as $file) { - $encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path)); - $callback = $this->encryptionService->decryptFileToCallback($encryptedPath, $key); + foreach ($share->files as $file) { + $encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($file->stored_path)); + $content = $this->encryptionService->decryptFile($encryptedPath, $key); - $filename = $file->relative_path ?: $file->original_name; - $filename = str_replace('\\', '/', $filename); + $filename = $file->relative_path ?: $file->original_name; + $filename = str_replace('\\', '/', $filename); - if (str_starts_with($filename, '/') || str_contains($filename, '..')) { - $filename = basename($filename); - } - - $zip->addFileFromCallback(fileName: $filename, callback: $callback, exactSize: $file->file_size); + if (str_starts_with($filename, '/') || str_contains($filename, '..')) { + $filename = basename($filename); } - $zip->finish(); - }, 200, [ + $zip->addFromString($filename, $content); + } + + $zip->close(); + + $this->shareService->recordDownload($share); + + return response()->download($tempPath, 'share-'.$share->token.'.zip', [ 'Content-Type' => 'application/zip', - 'Content-Disposition' => 'attachment; filename="share-'.$share->token.'.zip"', - ]); + ])->deleteFileAfterSend(true); } /** @@ -66,17 +66,27 @@ class DownloadController extends Controller $key = $this->resolveDecryptionKey($share); - $this->shareService->recordDownload($share); - $encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($shareFile->stored_path)); + $mimeType = $shareFile->mime_type ?? 'application/octet-stream'; - return $this->encryptionService->decryptFileStream( - $encryptedPath, - $key, - $shareFile->original_name, - $shareFile->mime_type ?? 'application/octet-stream', - $shareFile->file_size, - ); + $headers = [ + 'Content-Type' => $mimeType, + 'Content-Disposition' => \Symfony\Component\HttpFoundation\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); } /** diff --git a/app/Services/FileEncryptionService.php b/app/Services/FileEncryptionService.php index 25afb6d..8a6f7d9 100644 --- a/app/Services/FileEncryptionService.php +++ b/app/Services/FileEncryptionService.php @@ -2,7 +2,6 @@ namespace App\Services; -use Closure; use Generator; use RuntimeException; use Symfony\Component\HttpFoundation\HeaderUtils; @@ -181,30 +180,21 @@ class FileEncryptionService } /** - * Return a closure that decrypts a file into a temporary stream resource. - * Suitable for ZipStream's addFileFromCallback. + * Stream decrypted file content directly to output (echo). + * Use this when you need to add post-streaming logic inside a StreamedResponse callback. */ - public function decryptFileToCallback(string $encryptedPath, string $key): Closure + public function streamDecryptedFile(string $encryptedPath, string $key): void { - return function () use ($encryptedPath, $key) { - $tmp = tmpfile(); - - if ($tmp === false) { - throw new RuntimeException('Cannot create temporary file'); + if ($this->isChunkedFormat($encryptedPath)) { + foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) { + echo $chunk; + flush(); } - if ($this->isChunkedFormat($encryptedPath)) { - foreach ($this->decryptChunks($encryptedPath, $key) as $chunk) { - fwrite($tmp, $chunk); - } - } else { - fwrite($tmp, $this->decryptLegacy($encryptedPath, $key)); - } + return; + } - rewind($tmp); - - return $tmp; - }; + echo $this->decryptLegacy($encryptedPath, $key); } /** diff --git a/composer.json b/composer.json index 5fde80b..2300c38 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,6 @@ "laravel/octane": "^2.13", "laravel/tinker": "^2.10.1", "livewire/livewire": "^4.0", - "maennchen/zipstream-php": "^3.2", "robsontenorio/mary": "^2.7" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 0deea70..8d0fa7a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6b7f78bec30d422ca6805f3c503bd185", + "content-hash": "389e06b64c73b11ea3484bda4e3c5e21", "packages": [ { "name": "bacon/bacon-qr-code", @@ -2828,84 +2828,6 @@ ], "time": "2026-02-09T22:59:54+00:00" }, - { - "name": "maennchen/zipstream-php", - "version": "3.2.1", - "source": { - "type": "git", - "url": "https://github.com/maennchen/ZipStream-PHP.git", - "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5", - "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5", - "shasum": "" - }, - "require": { - "ext-mbstring": "*", - "ext-zlib": "*", - "php-64bit": "^8.3" - }, - "require-dev": { - "brianium/paratest": "^7.7", - "ext-zip": "*", - "friendsofphp/php-cs-fixer": "^3.86", - "guzzlehttp/guzzle": "^7.5", - "mikey179/vfsstream": "^1.6", - "php-coveralls/php-coveralls": "^2.5", - "phpunit/phpunit": "^12.0", - "vimeo/psalm": "^6.0" - }, - "suggest": { - "guzzlehttp/psr7": "^2.4", - "psr/http-message": "^2.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "ZipStream\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Paul Duncan", - "email": "pabs@pablotron.org" - }, - { - "name": "Jonatan Männchen", - "email": "jonatan@maennchen.ch" - }, - { - "name": "Jesse Donat", - "email": "donatj@gmail.com" - }, - { - "name": "András Kolesár", - "email": "kolesar@kolesar.hu" - } - ], - "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", - "keywords": [ - "stream", - "zip" - ], - "support": { - "issues": "https://github.com/maennchen/ZipStream-PHP/issues", - "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1" - }, - "funding": [ - { - "url": "https://github.com/maennchen", - "type": "github" - } - ], - "time": "2025-12-10T09:58:31+00:00" - }, { "name": "monolog/monolog", "version": "3.10.0", diff --git a/tests/Feature/ShareDownloadTest.php b/tests/Feature/ShareDownloadTest.php index 6d2e560..f218373 100644 --- a/tests/Feature/ShareDownloadTest.php +++ b/tests/Feature/ShareDownloadTest.php @@ -86,6 +86,7 @@ test('download counter increments on zip download', function () { $encryptionService = app(FileEncryptionService::class); $key = $share->encryption_key; + $content = 'test content'; foreach ($share->files as $file) { $dir = Storage::disk('shares')->path($share->token); @@ -94,17 +95,89 @@ test('download counter increments on zip download', function () { } $encryptedPath = $dir.'/'.basename($file->stored_path); $tempSource = tempnam(sys_get_temp_dir(), 'test'); - file_put_contents($tempSource, 'test content'); + file_put_contents($tempSource, $content); $encryptionService->encryptFile($tempSource, $encryptedPath, $key); unlink($tempSource); + $file->update(['file_size' => strlen($content)]); } - $this->withSession(['share_password_'.$share->token => null]) + $response = $this->withSession(['share_password_'.$share->token => null]) ->get(route('share.download.all', $share)); + $response->assertDownload(); + expect($share->fresh()->download_count)->toBe(1); }); +test('zip download produces a valid archive', function () { + Storage::fake('shares'); + + $share = createShareWithFile(); + $share->load('files'); + + $encryptionService = app(FileEncryptionService::class); + $key = $share->encryption_key; + $content = 'hello zip content'; + + foreach ($share->files as $file) { + $dir = Storage::disk('shares')->path($share->token); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + $encryptedPath = $dir.'/'.basename($file->stored_path); + $tempSource = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($tempSource, $content); + $encryptionService->encryptFile($tempSource, $encryptedPath, $key); + unlink($tempSource); + $file->update(['file_size' => strlen($content)]); + } + + $response = $this->get(route('share.download.all', $share)); + $response->assertDownload(); + + $zipPath = $response->getFile()->getPathname(); + + $zip = new \ZipArchive; + $result = $zip->open($zipPath); + + expect($result)->toBe(true); + expect($zip->numFiles)->toBe(1); + expect($zip->statIndex(0)['size'])->toBe(strlen($content)); + + $zip->close(); +}); + +test('last download streams successfully before auto-delete', function () { + Storage::fake('shares'); + + $share = createShareWithFile(); + $share->update(['max_downloads' => 1]); + $share->load('files'); + + $encryptionService = app(FileEncryptionService::class); + $key = $share->encryption_key; + $content = 'last download content'; + + foreach ($share->files as $file) { + $dir = Storage::disk('shares')->path($share->token); + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + $encryptedPath = $dir.'/'.basename($file->stored_path); + $tempSource = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($tempSource, $content); + $encryptionService->encryptFile($tempSource, $encryptedPath, $key); + unlink($tempSource); + $file->update(['file_size' => strlen($content)]); + } + + $response = $this->get(route('share.download.all', $share)); + $response->assertDownload(); + + // Share was deleted after download + expect(Share::query()->find($share->id))->toBeNull(); +}); + test('share auto-deletes after reaching download limit', function () { Storage::fake('shares');