diff --git a/README.md b/README.md index 6888189..10cc5e2 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ return [ | Save To Disk |-------------------------------------------------------------------------- | - | Set this to 'true' to save the zips to disk. + | Set this to 'true' to save the created zips to disk. | The saved file will be used the next time a user requests a zip with the same payload. | */ @@ -44,6 +44,17 @@ return [ 'disk' => 'public', + /* + |-------------------------------------------------------------------------- + | Link Expiry + |-------------------------------------------------------------------------- + | + | Set the time in minutes after which a link should expire. + | + */ + + 'expiry' => null, + ]; ``` @@ -61,16 +72,26 @@ images: Somehwere in your views: -```html +```antlers {{ zip:images }} ``` -You may optionally pass a filename using the `filename` parameter. The example below binds the name of the zip to the title of the current page. The filename defaults to the current timestamp. +### 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. -```html +```antlers {{ zip:images :filename='title' }} ``` +### Link Expiry + +If you want to expire your links after a certain time, you can either set the expiry globally in the config, or use the `expiry` parameter on the tag. The expiry is to be set in minutes. Note, that the expiry on the tag will overide the expiry in the config. + +```antlers +{{ zip:images expiry="60" }} +``` + ## Advanced Usage This addon also exposes two methods that let you get the route or create a zip programmatically. @@ -78,7 +99,7 @@ This addon also exposes two methods that let you get the route or create a zip p The `route` method returns the route that handles creating the zip. This is the same as using the `zip` tag in your views: ```php -\Aerni\Zipper\Zipper::route($files, $filename); +\Aerni\Zipper\Zipper::route($files, $filename, $expiry); ``` The `create` method creates and returns the zip directly: diff --git a/config/zipper.php b/config/zipper.php index 93f95a5..ff2424e 100644 --- a/config/zipper.php +++ b/config/zipper.php @@ -25,4 +25,15 @@ 'disk' => 'public', + /* + |-------------------------------------------------------------------------- + | Link Expiry + |-------------------------------------------------------------------------- + | + | Set the time in minutes after which a link should expire. + | + */ + + 'expiry' => null, + ]; diff --git a/routes/actions.php b/routes/actions.php index ccbb62c..cd8109b 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -3,4 +3,4 @@ use Aerni\Zipper\ZipperController; use Illuminate\Support\Facades\Route; -Route::get('/create/{files}', [ZipperController::class, 'create'])->name('zipper.create'); +Route::get('/create/{cipher}', [ZipperController::class, 'create'])->name('zipper.create'); diff --git a/src/Zipper.php b/src/Zipper.php index 3df47de..8ca731d 100644 --- a/src/Zipper.php +++ b/src/Zipper.php @@ -6,6 +6,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\URL; use League\Flysystem\AwsS3V3\AwsS3V3Adapter; use League\Flysystem\Local\LocalFilesystemAdapter; use Statamic\Contracts\Assets\Asset; @@ -16,12 +17,22 @@ class Zipper { - public static function route(Collection $files, ?string $filename = null): string + public static function route(Collection $files, ?string $filename = null, ?int $expiry = null): string { - return route('statamic.zipper.create', [ - 'files' => self::encrypt($files), - 'filename' => $filename, - ]); + $expiry = $expiry ?? config('zipper.expiry'); + + if (empty($expiry)) { + return URL::signedRoute( + 'statamic.zipper.create', + self::encrypt($files, $filename) + ); + } + + return URL::temporarySignedRoute( + 'statamic.zipper.create', + now()->addMinutes($expiry), + self::encrypt($files, $filename) + ); } public static function create(Collection $files, ?string $filename = null): mixed @@ -85,7 +96,7 @@ protected static function addFile(Asset|string $file, ZipStream $zip): ZipStream throw new Exception('Zipper doesn\'t support ['.$adapter::class.'].'); } - protected static function encrypt(Collection $files): string + protected static function encrypt(Collection $files, ?string $filename): string { $files = $files->map(fn ($file) => match (true) { ($file instanceof Asset) => $file->id(), @@ -93,14 +104,22 @@ protected static function encrypt(Collection $files): string default => throw new Exception('Unsupported file type. The file has to be a Statamic Asset, a URL or an absolute path.') }); - return Crypt::encryptString($files); + return Crypt::encrypt([ + 'files' => $files, + 'filename' => $filename, + ]); } - public static function decrypt(string $files): Collection + public static function decrypt(string $cipher): array { - $files = json_decode(Crypt::decryptString($files)); + $plaintext = Crypt::decrypt($cipher); + + $files = collect($plaintext['files'])->map(fn ($file) => AssetFacade::find($file) ?? $file); - return collect($files)->map(fn ($file) => AssetFacade::find($file) ?? $file); + return [ + 'files' => $files, + 'filename' => $plaintext['filename'], + ]; } protected static function filename(?string $filename): string diff --git a/src/ZipperController.php b/src/ZipperController.php index 2195828..7de0af5 100644 --- a/src/ZipperController.php +++ b/src/ZipperController.php @@ -7,15 +7,17 @@ class ZipperController extends Controller { - public function create(string $files, Request $request) + public function create(string $cipher, Request $request) { - $request->validate([ - 'filename' => 'sometimes|required|string', - ]); + if (! $request->hasValidSignature()) { + abort(401); + } + + $plaintext = Zipper::decrypt($cipher); return Zipper::create( - files: Zipper::decrypt($files), - filename: $request->get('filename') + files: $plaintext['files'], + filename: $plaintext['filename'], ); } } diff --git a/src/ZipperTags.php b/src/ZipperTags.php index 910aa4e..49c2815 100755 --- a/src/ZipperTags.php +++ b/src/ZipperTags.php @@ -22,7 +22,8 @@ public function wildcard(): string return Zipper::route( files: collect($files), - filename: $this->params->get('filename') + filename: $this->params->get('filename'), + expiry: $this->params->get('expiry'), ); } } diff --git a/tests/ZipperTagsTest.php b/tests/ZipperTagsTest.php index 5bce52d..417b15e 100644 --- a/tests/ZipperTagsTest.php +++ b/tests/ZipperTagsTest.php @@ -48,8 +48,9 @@ public function can_handle_a_single_asset() $url = $this->tag->wildcard(); - $files = Str::afterLast($url, '/'); - $file = Zipper::decrypt($files)[0]; + $uri = Str::afterLast($url, '/'); + $cipher = Str::before($uri, '?signature'); + $file = Zipper::decrypt($cipher)['files'][0]; $this->assertSame($value->value()->resolvedPath(), $file->resolvedPath()); } @@ -73,8 +74,9 @@ public function can_handle_multiple_assets() $url = $this->tag->wildcard(); - $files = Str::afterLast($url, '/'); - $files = Zipper::decrypt($files)->map(fn ($file) => $file->resolvedPath()); + $uri = Str::afterLast($url, '/'); + $cipher = Str::before($uri, '?signature'); + $files = Zipper::decrypt($cipher)['files']->map(fn ($file) => $file->resolvedPath()); $value->value()->get()->each(function ($file) use ($files) { $this->assertContains($file->resolvedPath(), $files);