]> BookStack Code Mirror - bookstack/commitdiff
Added the ability to replace existing image files
authorDan Brown <redacted>
Sun, 28 May 2023 16:32:22 +0000 (17:32 +0100)
committerDan Brown <redacted>
Sun, 28 May 2023 16:32:22 +0000 (17:32 +0100)
- 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.

app/Uploads/Controllers/ImageController.php
app/Uploads/ImageRepo.php
app/Uploads/ImageService.php
lang/en/components.php
lang/en/errors.php
resources/js/components/dropzone.js
resources/sass/_lists.scss
resources/views/pages/parts/image-manager-form.blade.php
routes/web.php
tests/Uploads/ImageTest.php

index fea0713a242f48cbe9cc8e60f584274af11dff10..2c611c515bff8746f87c868c0d5ec5f3dce1c0e4 100644 (file)
@@ -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.
      *
index 2d35d96ffa127ca167c3d8ac78af622e51693da2..e28e7b794ba50ed2ca1caad5b8f1d9989f04aaad 100644 (file)
@@ -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;
         }
index 5458779e943906db62a58cce8f12587a248d5139..66596a57f0980fc19da26a7eeaf0d5efb1458b6a 100644 (file)
@@ -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);
index a06c26d5bfaba658fa2ee1aae1a6d23ebf7dca5c..8a105096b4f6133295c637acaab526e44f4811a4 100644 (file)
@@ -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',
index 6991f96e4359facd80e789406e28054c8432b2b0..b03fb8c355aa7bc9e1b18b95035dbc1b42ea085d 100644 (file)
@@ -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
index 2b8b35081188f8754a744b5fd412a86076224670..1cac09b4a5d16e21baac1a6d2bc931b261827c7c 100644 (file)
@@ -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, {
index 33e500d6ad573ca555a5576292e8ef7f2241fcfb..ad0803e712596d4a753da39fa9401416b4907400 100644 (file)
@@ -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;
   }
index 66231a3568e1c1bd3594a3df862d4b2fa16c956f..75750ef2f76373057f58d94041db194bfbda01fe 100644 (file)
@@ -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') }}"
             <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">
index 92e0a003acdcd57ef9e8e1689cb35430fc7a67c4..48f6c27ba830aea57a56f048fae7c34156893cbc 100644 (file)
@@ -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']);
 
index 97e36001c63a92a44d012b4c5a1f23e2d51d8833..55d08dad1068859041a7053202b3738d22a883c3 100644 (file)
@@ -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();