diff --git a/README.md b/README.md index 10cc5e2..d5cc3ad 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,10 @@ Somehwere in your views: ### Filename -You may optionally pass a filename using the `filename` parameter. If you don't provide one, the filename will default to the timestamp at the time of download. The example below binds the name of the zip to the title of the page. +You may optionally pass a filename using the `filename` parameter. The filename defaults to the timestamp when the Zip object was created. The example below binds the name of the zip to the title of the page. ```antlers -{{ zip:images :filename='title' }} +{{ zip:images :filename="title" }} ``` ### Link Expiry @@ -94,26 +94,30 @@ If you want to expire your links after a certain time, you can either set the ex ## Advanced Usage -This addon also exposes two methods that let you get the route or create a zip programmatically. - -The `route` method returns the route that handles creating the zip. This is the same as using the `zip` tag in your views: +You may also use this addon programmatically as shown below. ```php -\Aerni\Zipper\Zipper::route($files, $filename, $expiry); -``` - -The `create` method creates and returns the zip directly: - -```php -\Aerni\Zipper\Zipper::create($files, $filename); -``` - -The `$files` need to be a collection of assets, paths or URLs: +use Aerni\Zipper\Zip; -```php -$files = collect([ +// Prepare an array of Statamic assets, paths or URLs. +$files = [ Statamic\Assets\Asset, '/home/ploi/site.com/storage/app/assets/file_1.jpg', 'https://site.com/path/to/file_2.jpg', -]) +]; + +// Make a zip with the files above. +$zip = Zip::make($files); + +// Set an optional filename. This defaults to the timestamp when the object was created. +$zip->filename('obi-wan-kenobi') + +// Set an optional expiry time in minutes. This defaults to the expiry set in the config. +$zip->expiry(60); + +// Get the URL that handles creating the zip. +$zip->url(); + +// Create a new zip or download a previously cached zip. +$zip->get(); ``` diff --git a/src/Zip.php b/src/Zip.php new file mode 100644 index 0000000..28283fb --- /dev/null +++ b/src/Zip.php @@ -0,0 +1,190 @@ +files($files) + ->filename(time()) + ->expiry((int) config('zipper.expiry')); + } + + public static function make(array $files): self + { + return new self($files); + } + + /** + * Get and set the files to zip. + */ + public function files(array $files = null): Collection|self + { + if (! func_get_args()) { + return $this->files; + } + + $this->files = collect($files); + + return $this; + } + + /** + * Get and set the filename of the zip. + */ + public function filename(string $filename = null): string|self + { + if (! func_get_args()) { + return $this->filename; + } + + // Make sure we never have an empty string as filename. + $this->filename = empty($filename) ? time() : $filename; + + return $this; + } + + /** + * Get and set the expiry of the zip route. + */ + public function expiry(int $expiry = null): int|self + { + if (! func_get_args()) { + return $this->expiry; + } + + $this->expiry = $expiry; + + return $this; + } + + /** + * Returns the route that handles creating the zip. + */ + public function url(): string + { + if (empty($this->expiry)) { + return URL::signedRoute('statamic.zipper.create', Crypt::encrypt($this)); + } + + return URL::temporarySignedRoute( + 'statamic.zipper.create', + now()->addMinutes($this->expiry), + Crypt::encrypt($this) + ); + } + + /** + * Create a new zip or download a previously cached zip. + */ + public function get(): ZipStream|StreamedResponse + { + return $this->shouldCacheZip() ? $this->cache() : $this->create(); + } + + /** + * Create and stream a new zip. + */ + protected function create(): ZipStream + { + $zip = ZipStreamFacade::create("{$this->filename}.zip"); + + $this->files->each(fn ($file) => $this->addFileToZip($file, $zip)); + + return $zip; + } + + /** + * Stream the zip while also caching it to disk for future requests. + * This let's us download previously cached zips instead of creating new ones. + */ + protected function cache(): ZipStream|StreamedResponse + { + $zip = $this->create(); + $filename = "{$zip->getFingerprint()}.zip"; + $disk = Storage::disk(config('zipper.disk')); + + if ($disk->exists($filename)) { + return $disk->download($filename, $zip->getName()); + } + + $adapter = $disk->getAdapter(); + + if ($adapter instanceof LocalFilesystemAdapter) { + return $zip->cache($disk->path($filename)); + } + + if ($adapter instanceof AwsS3V3Adapter) { + $path = "s3://{$disk->getConfig()['bucket']}/{$disk->path($filename)}"; + $s3Client = $disk->getClient(); + $file = File::make($path)->setS3Client($s3Client); + + return $zip->cache($file); + } + + throw new Exception('Zipper doesn\'t support ['.$adapter::class.'].'); + } + + /** + * Add a file to the zip. + */ + protected function addFileToZip(Asset|string $file, ZipStream $zip): ZipStream + { + if (is_string($file)) { + return $zip->add($file); + } + + $disk = $file->disk()->filesystem(); + $adapter = $disk->getAdapter(); + + if ($adapter instanceof LocalFilesystemAdapter) { + return $zip->add($file->resolvedPath()); + } + + if ($adapter instanceof AwsS3V3Adapter) { + $path = "s3://{$disk->getConfig()['bucket']}/{$file->path()}"; + $s3Client = $disk->getClient(); + $file = File::make($path)->setS3Client($s3Client); + + return $zip->add($file); + } + + throw new Exception('Zipper doesn\'t support ['.$adapter::class.'].'); + } + + /** + * The filename will be '0' if it isn't a timestamp. + */ + protected function hasCustomFilename(): bool + { + return (int) $this->filename === 0 ? true : false; + } + + /** + * If the zip doesn't have a custom filename, we would be endlessly caching + * new zips and never returning previously cached zips. + */ + protected function shouldCacheZip(): bool + { + return config('zipper.save') && $this->hasCustomFilename(); + } +} diff --git a/src/Zipper.php b/src/Zipper.php deleted file mode 100644 index 8ca731d..0000000 --- a/src/Zipper.php +++ /dev/null @@ -1,129 +0,0 @@ -addMinutes($expiry), - self::encrypt($files, $filename) - ); - } - - public static function create(Collection $files, ?string $filename = null): mixed - { - $zip = Zip::create(self::filename($filename)); - - $files->each(fn ($file) => self::addFile($file, $zip)); - - return $filename && config('zipper.save') - ? self::cache($zip) - : $zip; - } - - protected static function cache(ZipStream $zip): mixed - { - $disk = Storage::disk(config('zipper.disk')); - $filename = self::filename($zip->getFingerprint()); - - if ($disk->exists($filename)) { - return $disk->download($filename, $zip->getName()); - } - - $adapter = $disk->getAdapter(); - - if ($adapter instanceof LocalFilesystemAdapter) { - return $zip->cache($disk->path($filename)); - } - - if ($adapter instanceof AwsS3V3Adapter) { - $path = "s3://{$disk->getConfig()['bucket']}/{$disk->path($filename)}"; - $s3Client = $disk->getClient(); - $file = File::make($path)->setS3Client($s3Client); - - return $zip->cache($file); - } - - throw new Exception('Zipper doesn\'t support ['.$adapter::class.'].'); - } - - protected static function addFile(Asset|string $file, ZipStream $zip): ZipStream - { - if (is_string($file)) { - return $zip->add($file); - } - - $disk = $file->disk()->filesystem(); - $adapter = $disk->getAdapter(); - - if ($adapter instanceof LocalFilesystemAdapter) { - return $zip->add($file->resolvedPath()); - } - - if ($adapter instanceof AwsS3V3Adapter) { - $path = "s3://{$disk->getConfig()['bucket']}/{$file->path()}"; - $s3Client = $disk->getClient(); - $file = File::make($path)->setS3Client($s3Client); - - return $zip->add($file); - } - - throw new Exception('Zipper doesn\'t support ['.$adapter::class.'].'); - } - - protected static function encrypt(Collection $files, ?string $filename): string - { - $files = $files->map(fn ($file) => match (true) { - ($file instanceof Asset) => $file->id(), - (is_string($file)) => $file, - default => throw new Exception('Unsupported file type. The file has to be a Statamic Asset, a URL or an absolute path.') - }); - - return Crypt::encrypt([ - 'files' => $files, - 'filename' => $filename, - ]); - } - - public static function decrypt(string $cipher): array - { - $plaintext = Crypt::decrypt($cipher); - - $files = collect($plaintext['files'])->map(fn ($file) => AssetFacade::find($file) ?? $file); - - return [ - 'files' => $files, - 'filename' => $plaintext['filename'], - ]; - } - - protected static function filename(?string $filename): string - { - return $filename ? "{$filename}.zip" : time().'.zip'; - } -} diff --git a/src/ZipperController.php b/src/ZipperController.php index 44e6907..05d00e7 100644 --- a/src/ZipperController.php +++ b/src/ZipperController.php @@ -3,21 +3,21 @@ namespace Aerni\Zipper; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Crypt; use Statamic\Http\Controllers\Controller; +use STS\ZipStream\ZipStream; +use Symfony\Component\HttpFoundation\StreamedResponse; class ZipperController extends Controller { - public function create(string $cipher, Request $request) + public function create(string $cipher, Request $request): ZipStream|StreamedResponse { if (! $request->hasValidSignature()) { abort(403); } - $plaintext = Zipper::decrypt($cipher); + $zip = Crypt::decrypt($cipher); - return Zipper::create( - files: $plaintext['files'], - filename: $plaintext['filename'], - ); + return $zip->get(); } } diff --git a/src/ZipperTags.php b/src/ZipperTags.php index 49c2815..396c887 100755 --- a/src/ZipperTags.php +++ b/src/ZipperTags.php @@ -12,7 +12,7 @@ class ZipperTags extends Tags public function wildcard(): string { - $value = $this->context->get($this->method)?->value(); + $value = $this->context->value($this->method); $files = match (true) { ($value instanceof Asset) => [$value], // Handle asset fields with `max_files: 1`. @@ -20,10 +20,9 @@ public function wildcard(): string default => [], }; - return Zipper::route( - files: collect($files), - filename: $this->params->get('filename'), - expiry: $this->params->get('expiry'), - ); + return Zip::make($files) + ->filename($this->params->get('filename')) + ->expiry($this->params->get('expiry') ?? (int) config('zipper.expiry')) + ->url(); } } diff --git a/tests/ZipperTagsTest.php b/tests/ZipperTagsTest.php index 417b15e..dc6a6e1 100644 --- a/tests/ZipperTagsTest.php +++ b/tests/ZipperTagsTest.php @@ -2,8 +2,8 @@ namespace Aerni\Zipper\Tests; -use Aerni\Zipper\Zipper; use Aerni\Zipper\ZipperTags; +use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use Statamic\Fields\Field; @@ -50,7 +50,7 @@ public function can_handle_a_single_asset() $uri = Str::afterLast($url, '/'); $cipher = Str::before($uri, '?signature'); - $file = Zipper::decrypt($cipher)['files'][0]; + $file = Crypt::decrypt($cipher)->files()[0]; $this->assertSame($value->value()->resolvedPath(), $file->resolvedPath()); } @@ -76,7 +76,7 @@ public function can_handle_multiple_assets() $uri = Str::afterLast($url, '/'); $cipher = Str::before($uri, '?signature'); - $files = Zipper::decrypt($cipher)['files']->map(fn ($file) => $file->resolvedPath()); + $files = Crypt::decrypt($cipher)->files()->map(fn ($file) => $file->resolvedPath()); $value->value()->get()->each(function ($file) use ($files) { $this->assertContains($file->resolvedPath(), $files);