Add deferred upload

This commit is contained in:
philipp lang 2024-01-03 15:47:41 +01:00
parent c00ee87082
commit 8bf17784cc
10 changed files with 229 additions and 19 deletions

View File

@ -1,6 +1,6 @@
<?php <?php
return [ return [
'temp_storage' => 'temp', 'temp_disk' => 'temp',
'middleware' => ['web', 'auth:web'], 'middleware' => ['web', 'auth:web'],
]; ];

50
src/DeferredMediaData.php Normal file
View File

@ -0,0 +1,50 @@
<?php
namespace Zoomyboy\MedialibraryHelper;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\MediaCollections\MediaCollection;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
#[MapInputName(SnakeCaseMapper::class)]
#[MapOutputName(SnakeCaseMapper::class)]
class DeferredMediaData extends Data
{
public ?int $id;
public string $originalUrl;
public int $size;
public string $name;
public string $collectionName;
public string $fileName;
public string $mimeType;
public bool $fallback = false;
public bool $isDeferred = true;
public static function fromPath(string $path, MediaCollection $collection): self
{
$file = new MediaFile(Storage::disk(config('media-library.temp_disk'))->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,
]);
}
}

View File

@ -7,12 +7,12 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Spatie\Image\Image; use Spatie\Image\Image;
use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\DataCollection;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\MediaCollections\Exceptions\InvalidBase64Data; use Spatie\MediaLibrary\MediaCollections\Exceptions\InvalidBase64Data;
use Spatie\MediaLibrary\MediaCollections\FileAdder; use Spatie\MediaLibrary\MediaCollections\FileAdder;
use Spatie\MediaLibrary\MediaCollections\MediaCollection; use Spatie\MediaLibrary\MediaCollections\MediaCollection;
use Spatie\MediaLibrary\MediaCollections\Models\Media; use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Zoomyboy\MedialibraryHelper\Rules\ModelRule; use Zoomyboy\MedialibraryHelper\Rules\ModelRule;
use Illuminate\Support\Facades\Storage;
class MediaController class MediaController
{ {
@ -24,6 +24,10 @@ class MediaController
'parent' => ['required', new ModelRule()], 'parent' => ['required', new ModelRule()],
]); ]);
if (is_null($request->input('parent.id'))) {
return $this->storeDeferred($request);
}
$model = ModelRule::getModel($request->input('parent')); $model = ModelRule::getModel($request->input('parent'));
$collection = ModelRule::getCollection($request->input('parent')); $collection = ModelRule::getCollection($request->input('parent'));
$isSingle = 1 === $collection->collectionSizeLimit; $isSingle = 1 === $collection->collectionSizeLimit;
@ -62,6 +66,37 @@ class MediaController
return $isSingle ? MediaData::from($medias->first()) : MediaData::collection($medias); 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 public function update(Request $request, Media $media): MediaData
{ {
$this->authorize('updateMedia', [$media->model, $media->collection_name]); $this->authorize('updateMedia', [$media->model, $media->collection_name]);
@ -113,7 +148,7 @@ class MediaController
return property_exists($collection, $callback); 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); $maxWidth = $collection->runCallback('maxWidth', 9);
if (str_contains($data, ';base64')) { if (str_contains($data, ';base64')) {
@ -133,13 +168,20 @@ class MediaController
throw InvalidBase64Data::create(); throw InvalidBase64Data::create();
} }
$tmpFile = tempnam(sys_get_temp_dir(), 'media-library'); $tmpFile = 'media-library/' . str()->uuid()->toString();
file_put_contents($tmpFile, $binaryData); Storage::disk(config('media-library.temp_disk'))->put($tmpFile, $binaryData);
if (null !== $maxWidth && 'image/jpeg' === mime_content_type($tmpFile)) { if (null !== $maxWidth && 'image/jpeg' === Storage::disk(config('media-library.temp_disk'))->mimeType($tmpFile)) {
Image::load($tmpFile)->width($maxWidth)->save(); 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'));
} }
} }

View File

@ -37,11 +37,14 @@ class MediaData extends Data
public bool $fallback = false; public bool $fallback = false;
public bool $isDeferred = false;
public static function fromMedia(Media $media): self public static function fromMedia(Media $media): self
{ {
$conversions = collect($media->getMediaConversionNames())->flip()->map(fn ($integer, $conversion) => $media->hasGeneratedConversion($conversion) $conversions = collect($media->getMediaConversionNames())->flip()->map(
? ['original_url' => $media->getFullUrl($conversion)] fn ($integer, $conversion) => $media->hasGeneratedConversion($conversion)
: null, ? ['original_url' => $media->getFullUrl($conversion)]
: null,
); );
return self::withoutMagicalCreationFrom([ return self::withoutMagicalCreationFrom([

View File

@ -21,15 +21,20 @@ class ModelRule implements InvokableRule
public function __invoke($attribute, $value, $fail) public function __invoke($attribute, $value, $fail)
{ {
app(Factory::class)->make([$attribute => $value], [ 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}.model" => ['required', 'string', Rule::in(app('media-library-helpers')->keys())],
"{$attribute}.collection" => 'required|string', "{$attribute}.collection" => 'required|string',
])->validate(); ])->validate();
$this->model = data_get($value, 'model'); $this->model = data_get($value, 'model');
$this->id = data_get($value, 'id'); $this->id = data_get($value, 'id');
$this->collection = data_get($value, 'collection'); $this->collection = data_get($value, 'collection');
if (is_null($this->id)) {
$this->validateDeferred($attribute, $value);
return;
}
$model = app('media-library-helpers')->get($this->model); $model = app('media-library-helpers')->get($this->model);
app(Factory::class)->make([$attribute => $value], [ app(Factory::class)->make([$attribute => $value], [
@ -38,22 +43,47 @@ class ModelRule implements InvokableRule
])->validate(); ])->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 * @param array{?id: int, ?collection: string, ?model: string} $modelParam
*/ */
public static function getModel($modelParam): HasMedia public static function getModel($modelParam): HasMedia
{ {
$model = app('media-library-helpers')->get($modelParam['model']); $model = static::getModelClassName($modelParam);
return $model::find($modelParam['id']); return $model::find($modelParam['id']);
} }
/**
* @param array{?id: int, ?collection: string, ?model: string} $modelParam
* @return class-string<HasMedia>
*/
public static function getModelClassName($modelParam): string
{
return app('media-library-helpers')->get($modelParam['model']);
}
/** /**
* @param array{?id: int, ?collection: string, ?model: string} $modelParam * @param array{?id: int, ?collection: string, ?model: string} $modelParam
*/ */
public static function getCollection($modelParam): MediaCollection public static function getCollection($modelParam): MediaCollection
{ {
return static::getModel($modelParam)->getMediaCollection($modelParam['collection']); $className = static::getModelClassName($modelParam);
return (new $className)->getMediaCollection($modelParam['collection']);
} }
} }

View File

@ -0,0 +1,81 @@
<?php
namespace Zoomyboy\MedialibraryHelper\Tests\Feature;
use Illuminate\Support\Facades\Storage;
test('it uploads a deferred file to a collection', function () {
$this->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', ''];
});

View File

@ -20,5 +20,5 @@ test('modifies config file', function () {
$this->setUpTheTestEnvironment(); $this->setUpTheTestEnvironment();
$this->assertEquals('lala', config('media-library.image_driver')); $this->assertEquals('lala', config('media-library.image_driver'));
$this->assertEquals('temp', config('media-library.temp_storage')); $this->assertEquals('temp', config('media-library.temp_disk'));
}); });

View File

@ -29,12 +29,13 @@ test('it uploads a single file to a single file collection', function () {
$response->assertJsonPath('name', 'beispiel bild'); $response->assertJsonPath('name', 'beispiel bild');
$response->assertJsonPath('collection_name', 'defaultSingleFile'); $response->assertJsonPath('collection_name', 'defaultSingleFile');
$response->assertJsonPath('file_name', 'beispiel-bild.jpg'); $response->assertJsonPath('file_name', 'beispiel-bild.jpg');
$response->assertJsonPath('is_deferred', false);
$response->assertJsonMissingPath('model_type'); $response->assertJsonMissingPath('model_type');
$response->assertJsonMissingPath('model_id'); $response->assertJsonMissingPath('model_id');
}); });
test('it uploads a single image to a single file collection', function () { test('it uploads a single image to a single file collection', function () {
$this->auth()->registerModel(); $this->auth()->registerModel()->withoutExceptionHandling();
$post = $this->newPost(); $post = $this->newPost();
$content = base64_encode($this->jpgFile()->getContent()); $content = base64_encode($this->jpgFile()->getContent());
@ -289,7 +290,6 @@ test('it needs validation for single files', function (array $payloadOverwrites,
})->with(function () { })->with(function () {
yield [['parent.model' => 'missingmodel'], 'parent.model']; yield [['parent.model' => 'missingmodel'], 'parent.model'];
yield [['parent.id' => -1], 'parent.id']; yield [['parent.id' => -1], 'parent.id'];
yield [['parent.id' => ''], 'parent.id'];
yield [['parent.collection' => 'missingcollection'], 'parent.collection']; yield [['parent.collection' => 'missingcollection'], 'parent.collection'];
yield [['payload.content' => []], 'payload.content']; yield [['payload.content' => []], 'payload.content'];
yield [['payload.content' => ['UU']], 'payload.content']; yield [['payload.content' => ['UU']], 'payload.content'];

View File

@ -7,5 +7,5 @@ use Workbench\App\Policies\PostPolicy;
uses(Zoomyboy\MedialibraryHelper\Tests\TestCase::class)->in('Feature'); uses(Zoomyboy\MedialibraryHelper\Tests\TestCase::class)->in('Feature');
uses(Illuminate\Foundation\Testing\RefreshDatabase::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'); uses()->beforeEach(fn () => Gate::policy(Post::class, PostPolicy::class))->in('Feature');

View File

@ -20,12 +20,16 @@ class PostPolicy
&& data_get($user->policies, 'listMedia.collection') === $collection; && 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'])) { if (is_bool($user->policies['storeMedia'])) {
return $user->policies['storeMedia'] === true; 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 return data_get($user->policies, 'storeMedia.id') === $model->id
&& data_get($user->policies, 'storeMedia.collection') === $collection; && data_get($user->policies, 'storeMedia.collection') === $collection;
} }