From 8bf17784cc480b9d5d02d5a6079681c565805187 Mon Sep 17 00:00:00 2001 From: philipp lang Date: Wed, 3 Jan 2024 15:47:41 +0100 Subject: [PATCH] Add deferred upload --- config/media-library.php | 2 +- src/DeferredMediaData.php | 50 +++++++++++++ src/MediaController.php | 56 ++++++++++++-- src/MediaData.php | 9 ++- src/Rules/ModelRule.php | 36 ++++++++- tests/Feature/DeferredUploadTest.php | 81 +++++++++++++++++++++ tests/Feature/InstallTest.php | 2 +- tests/Feature/UploadTest.php | 4 +- tests/Pest.php | 2 +- tests/workbench/app/Policies/PostPolicy.php | 6 +- 10 files changed, 229 insertions(+), 19 deletions(-) create mode 100644 src/DeferredMediaData.php create mode 100644 tests/Feature/DeferredUploadTest.php diff --git a/config/media-library.php b/config/media-library.php index a478468..a492bb9 100644 --- a/config/media-library.php +++ b/config/media-library.php @@ -1,6 +1,6 @@ 'temp', + 'temp_disk' => 'temp', 'middleware' => ['web', 'auth:web'], ]; diff --git a/src/DeferredMediaData.php b/src/DeferredMediaData.php new file mode 100644 index 0000000..069881d --- /dev/null +++ b/src/DeferredMediaData.php @@ -0,0 +1,50 @@ +path($path)); + return static::withoutMagicalCreationFrom([ + 'collection_name' => $collection->name, + 'original_url' => Storage::disk(config('media-library.temp_disk'))->url($path), + 'size' => $file->getSize(), + 'name' => $file->getBasename(), + 'file_name' => $file->getFilename(), + 'mime_type' => Storage::disk(config('media-library.temp_disk'))->mimeType($path), + 'path' => $path, + ]); + } +} diff --git a/src/MediaController.php b/src/MediaController.php index c18a5e5..7fc891b 100644 --- a/src/MediaController.php +++ b/src/MediaController.php @@ -7,12 +7,12 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Spatie\Image\Image; use Spatie\LaravelData\DataCollection; -use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\MediaCollections\Exceptions\InvalidBase64Data; use Spatie\MediaLibrary\MediaCollections\FileAdder; use Spatie\MediaLibrary\MediaCollections\MediaCollection; use Spatie\MediaLibrary\MediaCollections\Models\Media; use Zoomyboy\MedialibraryHelper\Rules\ModelRule; +use Illuminate\Support\Facades\Storage; class MediaController { @@ -24,6 +24,10 @@ class MediaController 'parent' => ['required', new ModelRule()], ]); + if (is_null($request->input('parent.id'))) { + return $this->storeDeferred($request); + } + $model = ModelRule::getModel($request->input('parent')); $collection = ModelRule::getCollection($request->input('parent')); $isSingle = 1 === $collection->collectionSizeLimit; @@ -62,6 +66,37 @@ class MediaController return $isSingle ? MediaData::from($medias->first()) : MediaData::collection($medias); } + protected function storeDeferred(Request $request) + { + $modelName = ModelRule::getModelClassName($request->input('parent')); + $collection = ModelRule::getCollection($request->input('parent')); + $isSingle = 1 === $collection->collectionSizeLimit; + + $request->validate($isSingle ? [ + 'payload' => 'array', + 'payload.name' => 'required|string|regex:/\..*$/|max:255', + 'payload.content' => 'required|string', + ] : [ + 'payload' => 'required|array|min:1', + 'payload.*' => 'array', + 'payload.*.name' => 'required|string|regex:/\..*$/|max:255', + 'payload.*.content' => 'required|string', + ]); + + $content = $isSingle ? [$request->input('payload')] : $request->input('payload'); + + $tempPaths = collect($content)->map(function ($c) use ($collection, $modelName) { + $file = new MediaFile($c['name']); + + $tmpFile = $this->storeTemporaryFile($c['content'], $collection); + Storage::disk(config('media-library.temp_disk'))->move($tmpFile, 'media-library/' . $file->getFilename()); + return 'media-library/' . $file->getFilename(); + }); + + $this->authorize('storeMedia', [$modelName, null, $collection->name]); + return $isSingle ? DeferredMediaData::fromPath($tempPaths->first(), $collection) : DeferredMediaData::collectionFromPaths($tempPaths, $collection); + } + public function update(Request $request, Media $media): MediaData { $this->authorize('updateMedia', [$media->model, $media->collection_name]); @@ -113,7 +148,7 @@ class MediaController return property_exists($collection, $callback); } - protected function fileAdderFromData($model, $data, $collection): FileAdder + private function storeTemporaryFile(string $data, MediaCollection $collection): string { $maxWidth = $collection->runCallback('maxWidth', 9); if (str_contains($data, ';base64')) { @@ -133,13 +168,20 @@ class MediaController throw InvalidBase64Data::create(); } - $tmpFile = tempnam(sys_get_temp_dir(), 'media-library'); - file_put_contents($tmpFile, $binaryData); + $tmpFile = 'media-library/' . str()->uuid()->toString(); + Storage::disk(config('media-library.temp_disk'))->put($tmpFile, $binaryData); - if (null !== $maxWidth && 'image/jpeg' === mime_content_type($tmpFile)) { - Image::load($tmpFile)->width($maxWidth)->save(); + if (null !== $maxWidth && 'image/jpeg' === Storage::disk(config('media-library.temp_disk'))->mimeType($tmpFile)) { + Image::load(Storage::disk(config('media-library.temp_disk'))->path($tmpFile))->width($maxWidth)->save(); } - return $model->addMedia($tmpFile); + return $tmpFile; + } + + protected function fileAdderFromData($model, $data, $collection): FileAdder + { + $tmpFile = $this->storeTemporaryFile($data, $collection); + + return $model->addMediaFromDisk($tmpFile, config('media-library.temp_disk')); } } diff --git a/src/MediaData.php b/src/MediaData.php index 8bc82c0..f357d8e 100644 --- a/src/MediaData.php +++ b/src/MediaData.php @@ -37,11 +37,14 @@ class MediaData extends Data public bool $fallback = false; + public bool $isDeferred = false; + public static function fromMedia(Media $media): self { - $conversions = collect($media->getMediaConversionNames())->flip()->map(fn ($integer, $conversion) => $media->hasGeneratedConversion($conversion) - ? ['original_url' => $media->getFullUrl($conversion)] - : null, + $conversions = collect($media->getMediaConversionNames())->flip()->map( + fn ($integer, $conversion) => $media->hasGeneratedConversion($conversion) + ? ['original_url' => $media->getFullUrl($conversion)] + : null, ); return self::withoutMagicalCreationFrom([ diff --git a/src/Rules/ModelRule.php b/src/Rules/ModelRule.php index 9e3ca2f..1080a8b 100644 --- a/src/Rules/ModelRule.php +++ b/src/Rules/ModelRule.php @@ -21,15 +21,20 @@ class ModelRule implements InvokableRule public function __invoke($attribute, $value, $fail) { app(Factory::class)->make([$attribute => $value], [ - "{$attribute}.id" => 'required|integer|gt:0', + "{$attribute}.id" => 'nullable|integer|gt:0', "{$attribute}.model" => ['required', 'string', Rule::in(app('media-library-helpers')->keys())], "{$attribute}.collection" => 'required|string', ])->validate(); + $this->model = data_get($value, 'model'); $this->id = data_get($value, 'id'); $this->collection = data_get($value, 'collection'); + if (is_null($this->id)) { + $this->validateDeferred($attribute, $value); + return; + } $model = app('media-library-helpers')->get($this->model); app(Factory::class)->make([$attribute => $value], [ @@ -38,22 +43,47 @@ class ModelRule implements InvokableRule ])->validate(); } + public function validateDeferred($attribute, $value): void + { + app(Factory::class)->make([$attribute => $value], [ + "{$attribute}.model" => ['required', 'string', Rule::in(app('media-library-helpers')->keys())], + "{$attribute}.collection" => 'required|string', + ])->validate(); + + $model = app('media-library-helpers')->get($this->model); + + app(Factory::class)->make([$attribute => $value], [ + "{$attribute}.collection" => ['required', Rule::in((new $model())->getRegisteredMediaCollections()->pluck('name'))], + ])->validate(); + } + /** * @param array{?id: int, ?collection: string, ?model: string} $modelParam */ public static function getModel($modelParam): HasMedia { - $model = app('media-library-helpers')->get($modelParam['model']); + $model = static::getModelClassName($modelParam); return $model::find($modelParam['id']); } + /** + * @param array{?id: int, ?collection: string, ?model: string} $modelParam + * @return class-string + */ + public static function getModelClassName($modelParam): string + { + return app('media-library-helpers')->get($modelParam['model']); + } + /** * @param array{?id: int, ?collection: string, ?model: string} $modelParam */ public static function getCollection($modelParam): MediaCollection { - return static::getModel($modelParam)->getMediaCollection($modelParam['collection']); + $className = static::getModelClassName($modelParam); + + return (new $className)->getMediaCollection($modelParam['collection']); } } diff --git a/tests/Feature/DeferredUploadTest.php b/tests/Feature/DeferredUploadTest.php new file mode 100644 index 0000000..236ab87 --- /dev/null +++ b/tests/Feature/DeferredUploadTest.php @@ -0,0 +1,81 @@ +auth()->registerModel()->withoutExceptionHandling(); + $content = base64_encode($this->pdfFile()->getContent()); + + $payload = [ + 'parent' => ['model' => 'post', 'collection' => 'defaultSingleFile', 'id' => null], + 'payload' => [ + 'content' => $content, + 'name' => 'beispiel bild.jpg', + ], + ]; + + $this->postJson('/mediaupload', $payload) + ->assertStatus(201) + ->assertJson([ + 'is_deferred' => true, + 'original_url' => Storage::disk('temp')->url('media-library/beispiel bild.jpg'), + 'name' => 'beispiel bild', + 'collection_name' => 'defaultSingleFile', + 'size' => 3028, + 'file_name' => 'beispiel bild.jpg', + 'mime_type' => 'application/pdf', + ]); + Storage::disk('temp')->assertExists('media-library/beispiel bild.jpg'); +}); + +test('it handles authorization with collection', function () { + $this->auth(['storeMedia' => ['collection' => 'rtrt']])->registerModel(); + $content = base64_encode($this->pdfFile()->getContent()); + + $this->postJson('/mediaupload', [ + 'parent' => ['model' => 'post', 'collection' => 'defaultSingleFile', 'id' => null], + 'payload' => [ + 'content' => $content, + 'name' => 'beispiel bild.jpg', + ], + ])->assertStatus(403); +}); + +test('it handles authorization with collection correctly', function () { + $this->auth(['storeMedia' => ['collection' => 'defaultSingleFile']])->registerModel(); + $content = base64_encode($this->pdfFile()->getContent()); + + $this->postJson('/mediaupload', [ + 'parent' => ['model' => 'post', 'collection' => 'defaultSingleFile', 'id' => null], + 'payload' => [ + 'content' => $content, + 'name' => 'beispiel bild.jpg', + ], + ])->assertStatus(201); +}); + +test('it needs a collection', function ($key, $value) { + $this->auth()->registerModel(); + $content = base64_encode($this->pdfFile()->getContent()); + + $payload = [ + 'parent' => ['model' => 'post', 'collection' => '', 'id' => null], + 'payload' => [ + 'content' => $content, + 'name' => 'beispiel bild.jpg', + ], + ]; + + data_set($payload, $key, $value); + + $this->postJson('/mediaupload', $payload)->assertJsonValidationErrors($key); +})->with(function () { + yield ['parent.collection', '']; + yield ['parent.collection', -1]; + yield ['parent.collection', 'missingcollection']; + yield ['parent.model', 'lalala']; + yield ['parent.model', -1]; + yield ['parent.model', '']; +}); diff --git a/tests/Feature/InstallTest.php b/tests/Feature/InstallTest.php index 7679e04..830035d 100644 --- a/tests/Feature/InstallTest.php +++ b/tests/Feature/InstallTest.php @@ -20,5 +20,5 @@ test('modifies config file', function () { $this->setUpTheTestEnvironment(); $this->assertEquals('lala', config('media-library.image_driver')); - $this->assertEquals('temp', config('media-library.temp_storage')); + $this->assertEquals('temp', config('media-library.temp_disk')); }); diff --git a/tests/Feature/UploadTest.php b/tests/Feature/UploadTest.php index 2b43439..5c4e9a0 100644 --- a/tests/Feature/UploadTest.php +++ b/tests/Feature/UploadTest.php @@ -29,12 +29,13 @@ test('it uploads a single file to a single file collection', function () { $response->assertJsonPath('name', 'beispiel bild'); $response->assertJsonPath('collection_name', 'defaultSingleFile'); $response->assertJsonPath('file_name', 'beispiel-bild.jpg'); + $response->assertJsonPath('is_deferred', false); $response->assertJsonMissingPath('model_type'); $response->assertJsonMissingPath('model_id'); }); test('it uploads a single image to a single file collection', function () { - $this->auth()->registerModel(); + $this->auth()->registerModel()->withoutExceptionHandling(); $post = $this->newPost(); $content = base64_encode($this->jpgFile()->getContent()); @@ -289,7 +290,6 @@ test('it needs validation for single files', function (array $payloadOverwrites, })->with(function () { yield [['parent.model' => 'missingmodel'], 'parent.model']; yield [['parent.id' => -1], 'parent.id']; - yield [['parent.id' => ''], 'parent.id']; yield [['parent.collection' => 'missingcollection'], 'parent.collection']; yield [['payload.content' => []], 'payload.content']; yield [['payload.content' => ['UU']], 'payload.content']; diff --git a/tests/Pest.php b/tests/Pest.php index 9d2a6ec..0dc1a58 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -7,5 +7,5 @@ use Workbench\App\Policies\PostPolicy; uses(Zoomyboy\MedialibraryHelper\Tests\TestCase::class)->in('Feature'); uses(Illuminate\Foundation\Testing\RefreshDatabase::class)->in('Feature'); -uses()->beforeEach(fn () => Storage::fake('media'))->in('Feature'); +uses()->beforeEach(fn () => Storage::fake('media') && Storage::fake('temp'))->in('Feature'); uses()->beforeEach(fn () => Gate::policy(Post::class, PostPolicy::class))->in('Feature'); diff --git a/tests/workbench/app/Policies/PostPolicy.php b/tests/workbench/app/Policies/PostPolicy.php index e1e33a4..f76e272 100644 --- a/tests/workbench/app/Policies/PostPolicy.php +++ b/tests/workbench/app/Policies/PostPolicy.php @@ -20,12 +20,16 @@ class PostPolicy && data_get($user->policies, 'listMedia.collection') === $collection; } - public function storeMedia(User $user, HasMedia $model, string $collection): bool + public function storeMedia(User $user, ?HasMedia $model, ?string $collection = null): bool { if (is_bool($user->policies['storeMedia'])) { return $user->policies['storeMedia'] === true; } + if (is_null($model)) { + return data_get($user->policies, 'storeMedia.collection') === $collection; + } + return data_get($user->policies, 'storeMedia.id') === $model->id && data_get($user->policies, 'storeMedia.collection') === $collection; }