Added the ability to replace existing image files
- Updated UI with image form dropdown containing delete and replace image actions. - Adds new endpoint and service/repo handling for replacing existing image. - Includes tests to cover.
This commit is contained in:
		
							parent
							
								
									9ff7c97911
								
							
						
					
					
						commit
						e3c4a9d167
					
				| 
						 | 
				
			
			@ -14,13 +14,10 @@ use Illuminate\Validation\ValidationException;
 | 
			
		|||
 | 
			
		||||
class ImageController extends Controller
 | 
			
		||||
{
 | 
			
		||||
    protected ImageRepo $imageRepo;
 | 
			
		||||
    protected ImageService $imageService;
 | 
			
		||||
 | 
			
		||||
    public function __construct(ImageRepo $imageRepo, ImageService $imageService)
 | 
			
		||||
    {
 | 
			
		||||
        $this->imageRepo = $imageRepo;
 | 
			
		||||
        $this->imageService = $imageService;
 | 
			
		||||
    public function __construct(
 | 
			
		||||
        protected ImageRepo $imageRepo,
 | 
			
		||||
        protected ImageService $imageService
 | 
			
		||||
    ) {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -65,6 +62,29 @@ class ImageController extends Controller
 | 
			
		|||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the file for an existing image.
 | 
			
		||||
     */
 | 
			
		||||
    public function updateFile(Request $request, string $id)
 | 
			
		||||
    {
 | 
			
		||||
        $this->validate($request, [
 | 
			
		||||
            'file' => ['required', 'file', ...$this->getImageValidationRules()],
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        $image = $this->imageRepo->getById($id);
 | 
			
		||||
        $this->checkImagePermission($image);
 | 
			
		||||
        $this->checkOwnablePermission('image-update', $image);
 | 
			
		||||
        $file = $request->file('file');
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            $this->imageRepo->updateImageFile($image, $file);
 | 
			
		||||
        } catch (ImageUploadException $exception) {
 | 
			
		||||
            return $this->jsonError($exception->getMessage());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return response('');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the form for editing the given image.
 | 
			
		||||
     *
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,16 +11,10 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
 | 
			
		|||
 | 
			
		||||
class ImageRepo
 | 
			
		||||
{
 | 
			
		||||
    protected ImageService $imageService;
 | 
			
		||||
    protected PermissionApplicator $permissions;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * ImageRepo constructor.
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(ImageService $imageService, PermissionApplicator $permissions)
 | 
			
		||||
    {
 | 
			
		||||
        $this->imageService = $imageService;
 | 
			
		||||
        $this->permissions = $permissions;
 | 
			
		||||
    public function __construct(
 | 
			
		||||
        protected ImageService $imageService,
 | 
			
		||||
        protected PermissionApplicator $permissions
 | 
			
		||||
    ) {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -164,12 +158,29 @@ class ImageRepo
 | 
			
		|||
    public function updateImageDetails(Image $image, $updateDetails): Image
 | 
			
		||||
    {
 | 
			
		||||
        $image->fill($updateDetails);
 | 
			
		||||
        $image->updated_by = user()->id;
 | 
			
		||||
        $image->save();
 | 
			
		||||
        $this->loadThumbs($image);
 | 
			
		||||
 | 
			
		||||
        return $image;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Update the image file of an existing image in the system.
 | 
			
		||||
     * @throws ImageUploadException
 | 
			
		||||
     */
 | 
			
		||||
    public function updateImageFile(Image $image, UploadedFile $file): void
 | 
			
		||||
    {
 | 
			
		||||
        if ($file->getClientOriginalExtension() !== pathinfo($image->path, PATHINFO_EXTENSION)) {
 | 
			
		||||
            throw new ImageUploadException(trans('errors.image_upload_replace_type'));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $image->updated_by = user()->id;
 | 
			
		||||
        $image->save();
 | 
			
		||||
        $this->imageService->replaceExistingFromUpload($image->path, $image->type, $file);
 | 
			
		||||
        $this->loadThumbs($image, true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Destroys an Image object along with its revisions, files and thumbnails.
 | 
			
		||||
     *
 | 
			
		||||
| 
						 | 
				
			
			@ -202,11 +213,11 @@ class ImageRepo
 | 
			
		|||
    /**
 | 
			
		||||
     * Load thumbnails onto an image object.
 | 
			
		||||
     */
 | 
			
		||||
    public function loadThumbs(Image $image): void
 | 
			
		||||
    public function loadThumbs(Image $image, bool $forceCreate = false): void
 | 
			
		||||
    {
 | 
			
		||||
        $image->setAttribute('thumbs', [
 | 
			
		||||
            'gallery' => $this->getThumbnail($image, 150, 150, false),
 | 
			
		||||
            'display' => $this->getThumbnail($image, 1680, null, true),
 | 
			
		||||
            'gallery' => $this->getThumbnail($image, 150, 150, false, $forceCreate),
 | 
			
		||||
            'display' => $this->getThumbnail($image, 1680, null, true, $forceCreate),
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -215,10 +226,10 @@ class ImageRepo
 | 
			
		|||
     * If $keepRatio is true only the width will be used.
 | 
			
		||||
     * Checks the cache then storage to avoid creating / accessing the filesystem on every check.
 | 
			
		||||
     */
 | 
			
		||||
    protected function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio): ?string
 | 
			
		||||
    protected function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio, bool $forceCreate): ?string
 | 
			
		||||
    {
 | 
			
		||||
        try {
 | 
			
		||||
            return $this->imageService->getThumbnail($image, $width, $height, $keepRatio);
 | 
			
		||||
            return $this->imageService->getThumbnail($image, $width, $height, $keepRatio, $forceCreate);
 | 
			
		||||
        } catch (Exception $exception) {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -194,6 +194,14 @@ class ImageService
 | 
			
		|||
        return $image;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function replaceExistingFromUpload(string $path, string $type, UploadedFile $file): void
 | 
			
		||||
    {
 | 
			
		||||
        $imageData = file_get_contents($file->getRealPath());
 | 
			
		||||
        $storage = $this->getStorageDisk($type);
 | 
			
		||||
        $adjustedPath = $this->adjustPathForStorageDisk($path, $type);
 | 
			
		||||
        $storage->put($adjustedPath, $imageData);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Save image data for the given path in the public space, if possible,
 | 
			
		||||
     * for the provided storage mechanism.
 | 
			
		||||
| 
						 | 
				
			
			@ -262,7 +270,7 @@ class ImageService
 | 
			
		|||
     * @throws Exception
 | 
			
		||||
     * @throws InvalidArgumentException
 | 
			
		||||
     */
 | 
			
		||||
    public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false): string
 | 
			
		||||
    public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false, bool $forceCreate = false): string
 | 
			
		||||
    {
 | 
			
		||||
        // Do not resize GIF images where we're not cropping
 | 
			
		||||
        if ($keepRatio && $this->isGif($image)) {
 | 
			
		||||
| 
						 | 
				
			
			@ -277,13 +285,13 @@ class ImageService
 | 
			
		|||
 | 
			
		||||
        // Return path if in cache
 | 
			
		||||
        $cachedThumbPath = $this->cache->get($thumbCacheKey);
 | 
			
		||||
        if ($cachedThumbPath) {
 | 
			
		||||
        if ($cachedThumbPath && !$forceCreate) {
 | 
			
		||||
            return $this->getPublicUrl($cachedThumbPath);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // If thumbnail has already been generated, serve that and cache path
 | 
			
		||||
        $storage = $this->getStorageDisk($image->type);
 | 
			
		||||
        if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
 | 
			
		||||
        if (!$forceCreate && $storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
 | 
			
		||||
            $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
 | 
			
		||||
 | 
			
		||||
            return $this->getPublicUrl($thumbFilePath);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -32,6 +32,8 @@ return [
 | 
			
		|||
    'image_upload_success' => 'Image uploaded successfully',
 | 
			
		||||
    'image_update_success' => 'Image details successfully updated',
 | 
			
		||||
    'image_delete_success' => 'Image successfully deleted',
 | 
			
		||||
    'image_replace' => 'Replace Image',
 | 
			
		||||
    'image_replace_success' => 'Image file successfully updated',
 | 
			
		||||
 | 
			
		||||
    // Code Editor
 | 
			
		||||
    'code_editor' => 'Edit Code',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -49,6 +49,7 @@ return [
 | 
			
		|||
    // Drawing & Images
 | 
			
		||||
    'image_upload_error' => 'An error occurred uploading the image',
 | 
			
		||||
    'image_upload_type_error' => 'The image type being uploaded is invalid',
 | 
			
		||||
    'image_upload_replace_type' => 'Image file replacements must be of the same type',
 | 
			
		||||
    'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.',
 | 
			
		||||
 | 
			
		||||
    // Attachments
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,6 +15,7 @@ export class Dropzone extends Component {
 | 
			
		|||
        this.isActive = true;
 | 
			
		||||
 | 
			
		||||
        this.url = this.$opts.url;
 | 
			
		||||
        this.method = (this.$opts.method || 'post').toUpperCase();
 | 
			
		||||
        this.successMessage = this.$opts.successMessage;
 | 
			
		||||
        this.errorMessage = this.$opts.errorMessage;
 | 
			
		||||
        this.uploadLimitMb = Number(this.$opts.uploadLimit);
 | 
			
		||||
| 
						 | 
				
			
			@ -167,6 +168,9 @@ export class Dropzone extends Component {
 | 
			
		|||
    startXhrForUpload(upload) {
 | 
			
		||||
        const formData = new FormData();
 | 
			
		||||
        formData.append('file', upload.file, upload.file.name);
 | 
			
		||||
        if (this.method !== 'POST') {
 | 
			
		||||
            formData.append('_method', this.method);
 | 
			
		||||
        }
 | 
			
		||||
        const component = this;
 | 
			
		||||
 | 
			
		||||
        const req = window.$http.createXMLHttpRequest('POST', this.url, {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -674,6 +674,10 @@ ul.pagination {
 | 
			
		|||
  text-align: start !important;
 | 
			
		||||
  max-height: 500px;
 | 
			
		||||
  overflow-y: auto;
 | 
			
		||||
  &.anchor-left {
 | 
			
		||||
    inset-inline-end: auto;
 | 
			
		||||
    inset-inline-start: 0;
 | 
			
		||||
  }
 | 
			
		||||
  &.wide {
 | 
			
		||||
    min-width: 220px;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,14 @@
 | 
			
		|||
<div class="image-manager-details">
 | 
			
		||||
<div component="dropzone"
 | 
			
		||||
     option:dropzone:url="{{ url("/images/{$image->id}/file") }}"
 | 
			
		||||
     option:dropzone:method="PUT"
 | 
			
		||||
     option:dropzone:success-message="{{ trans('components.image_update_success') }}"
 | 
			
		||||
     option:dropzone:upload-limit="{{ config('app.upload_limit') }}"
 | 
			
		||||
     option:dropzone:upload-limit-message="{{ trans('errors.server_upload_limit') }}"
 | 
			
		||||
     option:dropzone:zone-text="{{ trans('entities.attachments_dropzone') }}"
 | 
			
		||||
     option:dropzone:file-accept="image/*"
 | 
			
		||||
     class="image-manager-details">
 | 
			
		||||
 | 
			
		||||
    <div refs="dropzone@status-area dropzone@drop-target"></div>
 | 
			
		||||
 | 
			
		||||
    <form component="ajax-form"
 | 
			
		||||
          option:ajax-form:success-message="{{ trans('components.image_update_success') }}"
 | 
			
		||||
| 
						 | 
				
			
			@ -19,22 +29,32 @@
 | 
			
		|||
            <input id="name" class="input-base" type="text" name="name" value="{{ $image->name }}">
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="flex-container-row justify-space-between gap-m">
 | 
			
		||||
            <div>
 | 
			
		||||
                @if(userCan('image-delete', $image))
 | 
			
		||||
                    <button type="button"
 | 
			
		||||
                        id="image-manager-delete"
 | 
			
		||||
                        title="{{ trans('common.delete') }}"
 | 
			
		||||
                        class="button icon outline">@icon('delete')</button>
 | 
			
		||||
                @endif
 | 
			
		||||
            </div>
 | 
			
		||||
            <div>
 | 
			
		||||
            @if(userCan('image-delete', $image) || userCan('image-update', $image))
 | 
			
		||||
                <div component="dropdown"
 | 
			
		||||
                     class="dropdown-container">
 | 
			
		||||
                    <button refs="dropdown@toggle" type="button" class="button icon outline">@icon('more')</button>
 | 
			
		||||
                    <div refs="dropdown@menu" class="dropdown-menu anchor-left">
 | 
			
		||||
                        @if(userCan('image-delete', $image))
 | 
			
		||||
                            <button type="button"
 | 
			
		||||
                                    id="image-manager-delete"
 | 
			
		||||
                                    class="text-item">{{ trans('common.delete') }}</button>
 | 
			
		||||
                        @endif
 | 
			
		||||
                        @if(userCan('image-update', $image))
 | 
			
		||||
                            <button type="button"
 | 
			
		||||
                                    id="image-manager-replace"
 | 
			
		||||
                                    refs="dropzone@select-button"
 | 
			
		||||
                                    class="text-item">{{ trans('components.image_replace') }}</button>
 | 
			
		||||
                        @endif
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            @endif
 | 
			
		||||
                <button type="submit"
 | 
			
		||||
                        class="button icon outline">{{ trans('common.save') }}</button>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </form>
 | 
			
		||||
 | 
			
		||||
    @if(!is_null($dependantPages))
 | 
			
		||||
        <hr>
 | 
			
		||||
        @if(count($dependantPages) > 0)
 | 
			
		||||
            <p class="text-neg mb-xs mt-m">{{ trans('components.image_delete_used') }}</p>
 | 
			
		||||
            <ul class="text-neg">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -140,6 +140,7 @@ Route::middleware('auth')->group(function () {
 | 
			
		|||
    Route::get('/images/drawio/base64/{id}', [UploadControllers\DrawioImageController::class, 'getAsBase64']);
 | 
			
		||||
    Route::post('/images/drawio', [UploadControllers\DrawioImageController::class, 'create']);
 | 
			
		||||
    Route::get('/images/edit/{id}', [UploadControllers\ImageController::class, 'edit']);
 | 
			
		||||
    Route::put('/images/{id}/file', [UploadControllers\ImageController::class, 'updateFile']);
 | 
			
		||||
    Route::put('/images/{id}', [UploadControllers\ImageController::class, 'update']);
 | 
			
		||||
    Route::delete('/images/{id}', [UploadControllers\ImageController::class, 'destroy']);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -92,6 +92,45 @@ class ImageTest extends TestCase
 | 
			
		|||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function test_image_file_update()
 | 
			
		||||
    {
 | 
			
		||||
        $page = $this->entities->page();
 | 
			
		||||
        $this->asEditor();
 | 
			
		||||
 | 
			
		||||
        $imgDetails = $this->files->uploadGalleryImageToPage($this, $page);
 | 
			
		||||
        $relPath = $imgDetails['path'];
 | 
			
		||||
 | 
			
		||||
        $newUpload = $this->files->uploadedImage('updated-image.png', 'compressed.png');
 | 
			
		||||
        $this->assertFileEquals($this->files->testFilePath('test-image.png'), public_path($relPath));
 | 
			
		||||
 | 
			
		||||
        $imageId = $imgDetails['response']->id;
 | 
			
		||||
        $this->call('PUT', "/images/{$imageId}/file", [], [], ['file' => $newUpload])
 | 
			
		||||
            ->assertOk();
 | 
			
		||||
 | 
			
		||||
        $this->assertFileEquals($this->files->testFilePath('compressed.png'), public_path($relPath));
 | 
			
		||||
 | 
			
		||||
        $this->files->deleteAtRelativePath($relPath);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function test_image_file_update_does_not_allow_change_in_image_extension()
 | 
			
		||||
    {
 | 
			
		||||
        $page = $this->entities->page();
 | 
			
		||||
        $this->asEditor();
 | 
			
		||||
 | 
			
		||||
        $imgDetails = $this->files->uploadGalleryImageToPage($this, $page);
 | 
			
		||||
        $relPath = $imgDetails['path'];
 | 
			
		||||
        $newUpload = $this->files->uploadedImage('updated-image.jpg', 'compressed.png');
 | 
			
		||||
 | 
			
		||||
        $imageId = $imgDetails['response']->id;
 | 
			
		||||
        $this->call('PUT', "/images/{$imageId}/file", [], [], ['file' => $newUpload])
 | 
			
		||||
            ->assertJson([
 | 
			
		||||
                "message" => "Image file replacements must be of the same type",
 | 
			
		||||
                "status" => "error",
 | 
			
		||||
            ]);
 | 
			
		||||
 | 
			
		||||
        $this->files->deleteAtRelativePath($relPath);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function test_gallery_get_list_format()
 | 
			
		||||
    {
 | 
			
		||||
        $this->asEditor();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue