]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #4578 from BookStackApp/upload_handling
authorDan Brown <redacted>
Sun, 1 Oct 2023 17:20:10 +0000 (17:20 +0000)
committerGitHub <redacted>
Sun, 1 Oct 2023 17:20:10 +0000 (17:20 +0000)
Improvements to file/image upload handling UX

29 files changed:
app/Entities/Models/Book.php
app/Entities/Models/Bookshelf.php
app/Entities/Tools/ExportFormatter.php
app/Exceptions/Handler.php
app/Uploads/Controllers/DrawioImageController.php
app/Uploads/Controllers/GalleryImageController.php
app/Uploads/Controllers/ImageController.php
app/Uploads/Controllers/ImageGalleryApiController.php
app/Uploads/Image.php
app/Uploads/ImageRepo.php
app/Uploads/ImageResizer.php [new file with mode: 0644]
app/Uploads/ImageService.php
app/Uploads/ImageStorage.php [new file with mode: 0644]
app/Uploads/ImageStorageDisk.php [new file with mode: 0644]
app/Users/Models/User.php
app/Util/OutOfMemoryHandler.php [new file with mode: 0644]
lang/en/components.php
lang/en/errors.php
resources/js/components/image-manager.js
resources/js/markdown/actions.js
resources/js/wysiwyg/drop-paste-handling.js
resources/js/wysiwyg/plugins-imagemanager.js
resources/sass/_components.scss
resources/views/pages/parts/image-manager-form.blade.php
resources/views/pages/parts/image-manager-list.blade.php
routes/web.php
tests/ErrorTest.php
tests/Helpers/FileProvider.php
tests/Uploads/ImageTest.php

index fc4556857c72a5d00ed9ec1bb68734489e973e36..f54a0bf2d6a464d859848ac1f97e674322562a5a 100644 (file)
@@ -40,26 +40,19 @@ class Book extends Entity implements HasCoverImage
 
     /**
      * Returns book cover image, if book cover not exists return default cover image.
-     *
-     * @param int $width  - Width of the image
-     * @param int $height - Height of the image
-     *
-     * @return string
      */
-    public function getBookCover($width = 440, $height = 250)
+    public function getBookCover(int $width = 440, int $height = 250): string
     {
         $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
-        if (!$this->image_id) {
+        if (!$this->image_id || !$this->cover) {
             return $default;
         }
 
         try {
-            $cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
+            return $this->cover->getThumb($width, $height, false) ?? $default;
         } catch (Exception $err) {
-            $cover = $default;
+            return $default;
         }
-
-        return $cover;
     }
 
     /**
index ad52d9d37d4f0ce7df280e7d0fd0052c1b79491b..4b44025a4c3e84eb3b00838cb4c32868e19d3b13 100644 (file)
@@ -3,6 +3,7 @@
 namespace BookStack\Entities\Models;
 
 use BookStack\Uploads\Image;
+use Exception;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -49,28 +50,21 @@ class Bookshelf extends Entity implements HasCoverImage
     }
 
     /**
-     * Returns BookShelf cover image, if cover does not exists return default cover image.
-     *
-     * @param int $width  - Width of the image
-     * @param int $height - Height of the image
-     *
-     * @return string
+     * Returns shelf cover image, if cover not exists return default cover image.
      */
-    public function getBookCover($width = 440, $height = 250)
+    public function getBookCover(int $width = 440, int $height = 250): string
     {
         // TODO - Make generic, focused on books right now, Perhaps set-up a better image
         $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
-        if (!$this->image_id) {
+        if (!$this->image_id || !$this->cover) {
             return $default;
         }
 
         try {
-            $cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
-        } catch (\Exception $err) {
-            $cover = $default;
+            return $this->cover->getThumb($width, $height, false) ?? $default;
+        } catch (Exception $err) {
+            return $default;
         }
-
-        return $cover;
     }
 
     /**
index 80b039b80f689df6a69ee78f8eb2ef460069fdae..9a8c687b08de8cf0c87166e6eec8b883bba0979c 100644 (file)
@@ -222,7 +222,7 @@ class ExportFormatter
             foreach ($imageTagsOutput[0] as $index => $imgMatch) {
                 $oldImgTagString = $imgMatch;
                 $srcString = $imageTagsOutput[2][$index];
-                $imageEncoded = $this->imageService->imageUriToBase64($srcString);
+                $imageEncoded = $this->imageService->imageUrlToBase64($srcString);
                 if ($imageEncoded === null) {
                     $imageEncoded = $srcString;
                 }
index 36bdf845d64d8905e930ab04385ff184f195a267..6a44200568c96db4eeed5b71c3ffcf480a1eba39 100644 (file)
@@ -6,9 +6,11 @@ use Exception;
 use Illuminate\Auth\AuthenticationException;
 use Illuminate\Database\Eloquent\ModelNotFoundException;
 use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
+use Illuminate\Http\Exceptions\PostTooLargeException;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
+use Symfony\Component\ErrorHandler\Error\FatalError;
 use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
 use Throwable;
 
@@ -35,6 +37,15 @@ class Handler extends ExceptionHandler
         'password_confirmation',
     ];
 
+    /**
+     * A function to run upon out of memory.
+     * If it returns a response, that will be provided back to the request
+     * upon an out of memory event.
+     *
+     * @var ?callable<?\Illuminate\Http\Response>
+     */
+    protected $onOutOfMemory = null;
+
     /**
      * Report or log an exception.
      *
@@ -59,6 +70,17 @@ class Handler extends ExceptionHandler
      */
     public function render($request, Throwable $e)
     {
+        if ($e instanceof FatalError && str_contains($e->getMessage(), 'bytes exhausted (tried to allocate') && $this->onOutOfMemory) {
+            $response = call_user_func($this->onOutOfMemory);
+            if ($response) {
+                return $response;
+            }
+        }
+
+        if ($e instanceof PostTooLargeException) {
+            $e = new NotifyException(trans('errors.server_post_limit'), '/', 413);
+        }
+
         if ($this->isApiRequest($request)) {
             return $this->renderApiException($e);
         }
@@ -66,12 +88,30 @@ class Handler extends ExceptionHandler
         return parent::render($request, $e);
     }
 
+    /**
+     * Provide a function to be called when an out of memory event occurs.
+     * If the callable returns a response, this response will be returned
+     * to the request upon error.
+     */
+    public function prepareForOutOfMemory(callable $onOutOfMemory)
+    {
+        $this->onOutOfMemory = $onOutOfMemory;
+    }
+
+    /**
+     * Forget the current out of memory handler, if existing.
+     */
+    public function forgetOutOfMemoryHandler()
+    {
+        $this->onOutOfMemory = null;
+    }
+
     /**
      * Check if the given request is an API request.
      */
     protected function isApiRequest(Request $request): bool
     {
-        return strpos($request->path(), 'api/') === 0;
+        return str_starts_with($request->path(), 'api/');
     }
 
     /**
index 35deada883bc211576541fbdd96ec5e0d781711e..6293da4f718b24189ae5c70a92529caf4c77a94b 100644 (file)
@@ -5,23 +5,23 @@ namespace BookStack\Uploads\Controllers;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Http\Controller;
 use BookStack\Uploads\ImageRepo;
+use BookStack\Uploads\ImageResizer;
+use BookStack\Util\OutOfMemoryHandler;
 use Exception;
 use Illuminate\Http\Request;
 
 class DrawioImageController extends Controller
 {
-    protected $imageRepo;
-
-    public function __construct(ImageRepo $imageRepo)
-    {
-        $this->imageRepo = $imageRepo;
+    public function __construct(
+        protected ImageRepo $imageRepo
+    ) {
     }
 
     /**
      * Get a list of gallery images, in a list.
      * Can be paged and filtered by entity.
      */
-    public function list(Request $request)
+    public function list(Request $request, ImageResizer $resizer)
     {
         $page = $request->get('page', 1);
         $searchTerm = $request->get('search', null);
@@ -29,11 +29,20 @@ class DrawioImageController extends Controller
         $parentTypeFilter = $request->get('filter_type', null);
 
         $imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
-
-        return view('pages.parts.image-manager-list', [
+        $viewData = [
+            'warning' => '',
             'images'  => $imgData['images'],
             'hasMore' => $imgData['has_more'],
-        ]);
+        ];
+
+        new OutOfMemoryHandler(function () use ($viewData) {
+            $viewData['warning'] = trans('errors.image_gallery_thumbnail_memory_limit');
+            return response()->view('pages.parts.image-manager-list', $viewData, 200);
+        });
+
+        $resizer->loadGalleryThumbnailsForMany($imgData['images']);
+
+        return view('pages.parts.image-manager-list', $viewData);
     }
 
     /**
index 33d3dd74c671b323bc2c503d14d82d28998a137a..258f2bef6bda19c48e58a12b9d46280084d9997e 100644 (file)
@@ -5,7 +5,11 @@ namespace BookStack\Uploads\Controllers;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Http\Controller;
 use BookStack\Uploads\ImageRepo;
+use BookStack\Uploads\ImageResizer;
+use BookStack\Util\OutOfMemoryHandler;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\Log;
 use Illuminate\Validation\ValidationException;
 
 class GalleryImageController extends Controller
@@ -19,7 +23,7 @@ class GalleryImageController extends Controller
      * Get a list of gallery images, in a list.
      * Can be paged and filtered by entity.
      */
-    public function list(Request $request)
+    public function list(Request $request, ImageResizer $resizer)
     {
         $page = $request->get('page', 1);
         $searchTerm = $request->get('search', null);
@@ -27,11 +31,20 @@ class GalleryImageController extends Controller
         $parentTypeFilter = $request->get('filter_type', null);
 
         $imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 30, $uploadedToFilter, $searchTerm);
-
-        return view('pages.parts.image-manager-list', [
+        $viewData = [
+            'warning' => '',
             'images'  => $imgData['images'],
             'hasMore' => $imgData['has_more'],
-        ]);
+        ];
+
+        new OutOfMemoryHandler(function () use ($viewData) {
+            $viewData['warning'] = trans('errors.image_gallery_thumbnail_memory_limit');
+            return response()->view('pages.parts.image-manager-list', $viewData, 200);
+        });
+
+        $resizer->loadGalleryThumbnailsForMany($imgData['images']);
+
+        return view('pages.parts.image-manager-list', $viewData);
     }
 
     /**
@@ -51,6 +64,10 @@ class GalleryImageController extends Controller
             return $this->jsonError(implode("\n", $exception->errors()['file']));
         }
 
+        new OutOfMemoryHandler(function () {
+            return $this->jsonError(trans('errors.image_upload_memory_limit'));
+        });
+
         try {
             $imageUpload = $request->file('file');
             $uploadedTo = $request->get('uploaded_to', 0);
index 2c611c515bff8746f87c868c0d5ec5f3dce1c0e4..c68ffdf6bd0361fae8f904b38499dbe0ea0a5bff 100644 (file)
@@ -4,19 +4,22 @@ namespace BookStack\Uploads\Controllers;
 
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\NotFoundException;
+use BookStack\Exceptions\NotifyException;
 use BookStack\Http\Controller;
 use BookStack\Uploads\Image;
 use BookStack\Uploads\ImageRepo;
+use BookStack\Uploads\ImageResizer;
 use BookStack\Uploads\ImageService;
+use BookStack\Util\OutOfMemoryHandler;
 use Exception;
 use Illuminate\Http\Request;
-use Illuminate\Validation\ValidationException;
 
 class ImageController extends Controller
 {
     public function __construct(
         protected ImageRepo $imageRepo,
-        protected ImageService $imageService
+        protected ImageService $imageService,
+        protected ImageResizer $imageResizer,
     ) {
     }
 
@@ -38,13 +41,10 @@ class ImageController extends Controller
 
     /**
      * Update image details.
-     *
-     * @throws ImageUploadException
-     * @throws ValidationException
      */
     public function update(Request $request, string $id)
     {
-        $this->validate($request, [
+        $data = $this->validate($request, [
             'name' => ['required', 'min:2', 'string'],
         ]);
 
@@ -52,9 +52,7 @@ class ImageController extends Controller
         $this->checkImagePermission($image);
         $this->checkOwnablePermission('image-update', $image);
 
-        $image = $this->imageRepo->updateImageDetails($image, $request->all());
-
-        $this->imageRepo->loadThumbs($image);
+        $image = $this->imageRepo->updateImageDetails($image, $data);
 
         return view('pages.parts.image-manager-form', [
             'image'          => $image,
@@ -76,6 +74,10 @@ class ImageController extends Controller
         $this->checkOwnablePermission('image-update', $image);
         $file = $request->file('file');
 
+        new OutOfMemoryHandler(function () {
+            return $this->jsonError(trans('errors.image_upload_memory_limit'));
+        });
+
         try {
             $this->imageRepo->updateImageFile($image, $file);
         } catch (ImageUploadException $exception) {
@@ -99,12 +101,20 @@ class ImageController extends Controller
             $dependantPages = $this->imageRepo->getPagesUsingImage($image);
         }
 
-        $this->imageRepo->loadThumbs($image);
-
-        return view('pages.parts.image-manager-form', [
+        $viewData = [
             'image'          => $image,
             'dependantPages' => $dependantPages ?? null,
-        ]);
+            'warning'        => '',
+        ];
+
+        new OutOfMemoryHandler(function () use ($viewData) {
+            $viewData['warning'] = trans('errors.image_thumbnail_memory_limit');
+            return response()->view('pages.parts.image-manager-form', $viewData);
+        });
+
+        $this->imageResizer->loadGalleryThumbnailsForImage($image, false);
+
+        return view('pages.parts.image-manager-form', $viewData);
     }
 
     /**
@@ -123,10 +133,29 @@ class ImageController extends Controller
         return response('');
     }
 
+    /**
+     * Rebuild the thumbnails for the given image.
+     */
+    public function rebuildThumbnails(string $id)
+    {
+        $image = $this->imageRepo->getById($id);
+        $this->checkImagePermission($image);
+        $this->checkOwnablePermission('image-update', $image);
+
+        new OutOfMemoryHandler(function () {
+            return $this->jsonError(trans('errors.image_thumbnail_memory_limit'));
+        });
+
+        $this->imageResizer->loadGalleryThumbnailsForImage($image, true);
+
+        return response(trans('components.image_rebuild_thumbs_success'));
+    }
+
     /**
      * Check related page permission and ensure type is drawio or gallery.
+     * @throws NotifyException
      */
-    protected function checkImagePermission(Image $image)
+    protected function checkImagePermission(Image $image): void
     {
         if ($image->type !== 'drawio' && $image->type !== 'gallery') {
             $this->showPermissionError();
index 0d35d2905f71dbeda2302539760baff2119a36c2..ec96e4593bc2fa48f71e0e221a5c27be69278156 100644 (file)
@@ -6,6 +6,7 @@ use BookStack\Entities\Models\Page;
 use BookStack\Http\ApiController;
 use BookStack\Uploads\Image;
 use BookStack\Uploads\ImageRepo;
+use BookStack\Uploads\ImageResizer;
 use Illuminate\Http\Request;
 
 class ImageGalleryApiController extends ApiController
@@ -15,7 +16,8 @@ class ImageGalleryApiController extends ApiController
     ];
 
     public function __construct(
-        protected ImageRepo $imageRepo
+        protected ImageRepo $imageRepo,
+        protected ImageResizer $imageResizer,
     ) {
     }
 
@@ -130,7 +132,7 @@ class ImageGalleryApiController extends ApiController
      */
     protected function formatForSingleResponse(Image $image): array
     {
-        $this->imageRepo->loadThumbs($image);
+        $this->imageResizer->loadGalleryThumbnailsForImage($image, false);
         $data = $image->toArray();
         $data['created_by'] = $image->createdBy;
         $data['updated_by'] = $image->updatedBy;
@@ -138,6 +140,7 @@ class ImageGalleryApiController extends ApiController
 
         $escapedUrl = htmlentities($image->url);
         $escapedName = htmlentities($image->name);
+
         if ($image->type === 'drawio') {
             $data['content']['html'] = "<div drawio-diagram=\"{$image->id}\"><img src=\"{$escapedUrl}\"></div>";
             $data['content']['markdown'] = $data['content']['html'];
index 9f571693a0ab68830faedc90c212d8e47e5d6b47..0a267a64465ff623ae92c001cd68caf4666f38ef 100644 (file)
@@ -45,13 +45,14 @@ class Image extends Model
     }
 
     /**
-     * Get a thumbnail for this image.
+     * Get a thumbnail URL for this image.
+     * Attempts to generate the thumbnail if not already existing.
      *
      * @throws \Exception
      */
-    public function getThumb(?int $width, ?int $height, bool $keepRatio = false): string
+    public function getThumb(?int $width, ?int $height, bool $keepRatio = false): ?string
     {
-        return app()->make(ImageService::class)->getThumbnail($this, $width, $height, $keepRatio);
+        return app()->make(ImageResizer::class)->resizeToThumbnailUrl($this, $width, $height, $keepRatio, false);
     }
 
     /**
index 5507933f3fafbbb8e9025a52eb0674ce12b4c8ca..0e312d8832730b678e2c35502604966247037761 100644 (file)
@@ -13,7 +13,8 @@ class ImageRepo
 {
     public function __construct(
         protected ImageService $imageService,
-        protected PermissionApplicator $permissions
+        protected PermissionApplicator $permissions,
+        protected ImageResizer $imageResizer,
     ) {
     }
 
@@ -29,19 +30,13 @@ class ImageRepo
      * Execute a paginated query, returning in a standard format.
      * Also runs the query through the restriction system.
      */
-    private function returnPaginated($query, $page = 1, $pageSize = 24): array
+    protected function returnPaginated(Builder $query, int $page = 1, int $pageSize = 24): array
     {
         $images = $query->orderBy('created_at', 'desc')->skip($pageSize * ($page - 1))->take($pageSize + 1)->get();
-        $hasMore = count($images) > $pageSize;
-
-        $returnImages = $images->take($pageSize);
-        $returnImages->each(function (Image $image) {
-            $this->loadThumbs($image);
-        });
 
         return [
-            'images'   => $returnImages,
-            'has_more' => $hasMore,
+            'images'   => $images->take($pageSize),
+            'has_more' => count($images) > $pageSize,
         ];
     }
 
@@ -119,7 +114,7 @@ class ImageRepo
         $image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio);
 
         if ($type !== 'system') {
-            $this->loadThumbs($image);
+            $this->imageResizer->loadGalleryThumbnailsForImage($image, true);
         }
 
         return $image;
@@ -133,7 +128,7 @@ class ImageRepo
     public function saveNewFromData(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
     {
         $image = $this->imageService->saveNew($imageName, $imageData, $type, $uploadedTo);
-        $this->loadThumbs($image);
+        $this->imageResizer->loadGalleryThumbnailsForImage($image, true);
 
         return $image;
     }
@@ -160,7 +155,7 @@ class ImageRepo
         $image->fill($updateDetails);
         $image->updated_by = user()->id;
         $image->save();
-        $this->loadThumbs($image);
+        $this->imageResizer->loadGalleryThumbnailsForImage($image, false);
 
         return $image;
     }
@@ -179,8 +174,9 @@ class ImageRepo
         $image->updated_by = user()->id;
         $image->touch();
         $image->save();
+
         $this->imageService->replaceExistingFromUpload($image->path, $image->type, $file);
-        $this->loadThumbs($image, true);
+        $this->imageResizer->loadGalleryThumbnailsForImage($image, true);
     }
 
     /**
@@ -212,31 +208,6 @@ class ImageRepo
         }
     }
 
-    /**
-     * Load thumbnails onto an image object.
-     */
-    public function loadThumbs(Image $image, bool $forceCreate = false): void
-    {
-        $image->setAttribute('thumbs', [
-            'gallery' => $this->getThumbnail($image, 150, 150, false, $forceCreate),
-            'display' => $this->getThumbnail($image, 1680, null, true, $forceCreate),
-        ]);
-    }
-
-    /**
-     * Get the thumbnail for an image.
-     * 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, bool $forceCreate): ?string
-    {
-        try {
-            return $this->imageService->getThumbnail($image, $width, $height, $keepRatio, $forceCreate);
-        } catch (Exception $exception) {
-            return null;
-        }
-    }
-
     /**
      * Get the raw image data from an Image.
      */
diff --git a/app/Uploads/ImageResizer.php b/app/Uploads/ImageResizer.php
new file mode 100644 (file)
index 0000000..0d090a9
--- /dev/null
@@ -0,0 +1,206 @@
+<?php
+
+namespace BookStack\Uploads;
+
+use BookStack\Exceptions\ImageUploadException;
+use Exception;
+use GuzzleHttp\Psr7\Utils;
+use Illuminate\Support\Facades\Cache;
+use Intervention\Image\Image as InterventionImage;
+use Intervention\Image\ImageManager;
+
+class ImageResizer
+{
+    protected const THUMBNAIL_CACHE_TIME = 604_800; // 1 week
+
+    public function __construct(
+        protected ImageManager $intervention,
+        protected ImageStorage $storage,
+    ) {
+    }
+
+    /**
+     * Load gallery thumbnails for a set of images.
+     * @param iterable<Image> $images
+     */
+    public function loadGalleryThumbnailsForMany(iterable $images, bool $shouldCreate = false): void
+    {
+        foreach ($images as $image) {
+            $this->loadGalleryThumbnailsForImage($image, $shouldCreate);
+        }
+    }
+
+    /**
+     * Load gallery thumbnails into the given image instance.
+     */
+    public function loadGalleryThumbnailsForImage(Image $image, bool $shouldCreate): void
+    {
+        $thumbs = ['gallery' => null, 'display' => null];
+
+        try {
+            $thumbs['gallery'] = $this->resizeToThumbnailUrl($image, 150, 150, false, $shouldCreate);
+            $thumbs['display'] = $this->resizeToThumbnailUrl($image, 1680, null, true, $shouldCreate);
+        } catch (Exception $exception) {
+            // Prevent thumbnail errors from stopping execution
+        }
+
+        $image->setAttribute('thumbs', $thumbs);
+    }
+
+    /**
+     * Get the thumbnail for an image.
+     * If $keepRatio is true only the width will be used.
+     * Checks the cache then storage to avoid creating / accessing the filesystem on every check.
+     *
+     * @throws Exception
+     */
+    public function resizeToThumbnailUrl(
+        Image $image,
+        ?int $width,
+        ?int $height,
+        bool $keepRatio = false,
+        bool $shouldCreate = false
+    ): ?string {
+        // Do not resize GIF images where we're not cropping
+        if ($keepRatio && $this->isGif($image)) {
+            return $this->storage->getPublicUrl($image->path);
+        }
+
+        $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
+        $imagePath = $image->path;
+        $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
+
+        $thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
+
+        // Return path if in cache
+        $cachedThumbPath = Cache::get($thumbCacheKey);
+        if ($cachedThumbPath && !$shouldCreate) {
+            return $this->storage->getPublicUrl($cachedThumbPath);
+        }
+
+        // If thumbnail has already been generated, serve that and cache path
+        $disk = $this->storage->getDisk($image->type);
+        if (!$shouldCreate && $disk->exists($thumbFilePath)) {
+            Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
+
+            return $this->storage->getPublicUrl($thumbFilePath);
+        }
+
+        $imageData = $disk->get($imagePath);
+
+        // Do not resize apng images where we're not cropping
+        if ($keepRatio && $this->isApngData($image, $imageData)) {
+            Cache::put($thumbCacheKey, $image->path, static::THUMBNAIL_CACHE_TIME);
+
+            return $this->storage->getPublicUrl($image->path);
+        }
+
+        // If not in cache and thumbnail does not exist, generate thumb and cache path
+        $thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio);
+        $disk->put($thumbFilePath, $thumbData, true);
+        Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
+
+        return $this->storage->getPublicUrl($thumbFilePath);
+    }
+
+    /**
+     * Resize the image of given data to the specified size, and return the new image data.
+     *
+     * @throws ImageUploadException
+     */
+    public function resizeImageData(string $imageData, ?int $width, ?int $height, bool $keepRatio): string
+    {
+        try {
+            $thumb = $this->intervention->make($imageData);
+        } catch (Exception $e) {
+            throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
+        }
+
+        $this->orientImageToOriginalExif($thumb, $imageData);
+
+        if ($keepRatio) {
+            $thumb->resize($width, $height, function ($constraint) {
+                $constraint->aspectRatio();
+                $constraint->upsize();
+            });
+        } else {
+            $thumb->fit($width, $height);
+        }
+
+        $thumbData = (string) $thumb->encode();
+
+        // Use original image data if we're keeping the ratio
+        // and the resizing does not save any space.
+        if ($keepRatio && strlen($thumbData) > strlen($imageData)) {
+            return $imageData;
+        }
+
+        return $thumbData;
+    }
+
+    /**
+     * Orientate the given intervention image based upon the given original image data.
+     * Intervention does have an `orientate` method but the exif data it needs is lost before it
+     * can be used (At least when created using binary string data) so we need to do some
+     * implementation on our side to use the original image data.
+     * Bulk of logic taken from: https://github.com/Intervention/image/blob/b734a4988b2148e7d10364b0609978a88d277536/src/Intervention/Image/Commands/OrientateCommand.php
+     * Copyright (c) Oliver Vogel, MIT License.
+     */
+    protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
+    {
+        if (!extension_loaded('exif')) {
+            return;
+        }
+
+        $stream = Utils::streamFor($originalData)->detach();
+        $exif = @exif_read_data($stream);
+        $orientation = $exif ? ($exif['Orientation'] ?? null) : null;
+
+        switch ($orientation) {
+            case 2:
+                $image->flip();
+                break;
+            case 3:
+                $image->rotate(180);
+                break;
+            case 4:
+                $image->rotate(180)->flip();
+                break;
+            case 5:
+                $image->rotate(270)->flip();
+                break;
+            case 6:
+                $image->rotate(270);
+                break;
+            case 7:
+                $image->rotate(90)->flip();
+                break;
+            case 8:
+                $image->rotate(90);
+                break;
+        }
+    }
+
+    /**
+     * Checks if the image is a gif. Returns true if it is, else false.
+     */
+    protected function isGif(Image $image): bool
+    {
+        return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
+    }
+
+    /**
+     * Check if the given image and image data is apng.
+     */
+    protected function isApngData(Image $image, string &$imageData): bool
+    {
+        $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
+        if (!$isPng) {
+            return false;
+        }
+
+        $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
+
+        return str_contains($initialHeader, 'acTL');
+    }
+}
index 66596a57f0980fc19da26a7eeaf0d5efb1458b6a..1655a4cc3aa0d9c74a12f355a8cbb6880f011711 100644 (file)
@@ -6,109 +6,27 @@ use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Page;
 use BookStack\Exceptions\ImageUploadException;
-use ErrorException;
 use Exception;
-use GuzzleHttp\Psr7\Utils;
-use Illuminate\Contracts\Cache\Repository as Cache;
-use Illuminate\Contracts\Filesystem\FileNotFoundException;
-use Illuminate\Contracts\Filesystem\Filesystem as Storage;
-use Illuminate\Filesystem\FilesystemAdapter;
-use Illuminate\Filesystem\FilesystemManager;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
-use Intervention\Image\Exception\NotSupportedException;
-use Intervention\Image\Image as InterventionImage;
-use Intervention\Image\ImageManager;
-use League\Flysystem\WhitespacePathNormalizer;
-use Psr\SimpleCache\InvalidArgumentException;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 use Symfony\Component\HttpFoundation\StreamedResponse;
 
 class ImageService
 {
-    protected ImageManager $imageTool;
-    protected Cache $cache;
-    protected FilesystemManager $fileSystem;
-
     protected static array $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
 
-    public function __construct(ImageManager $imageTool, FilesystemManager $fileSystem, Cache $cache)
-    {
-        $this->imageTool = $imageTool;
-        $this->fileSystem = $fileSystem;
-        $this->cache = $cache;
-    }
-
-    /**
-     * Get the storage that will be used for storing images.
-     */
-    protected function getStorageDisk(string $imageType = ''): Storage
-    {
-        return $this->fileSystem->disk($this->getStorageDiskName($imageType));
-    }
-
-    /**
-     * Check if local secure image storage (Fetched behind authentication)
-     * is currently active in the instance.
-     */
-    protected function usingSecureImages(string $imageType = 'gallery'): bool
-    {
-        return $this->getStorageDiskName($imageType) === 'local_secure_images';
-    }
-
-    /**
-     * Check if "local secure restricted" (Fetched behind auth, with permissions enforced)
-     * is currently active in the instance.
-     */
-    protected function usingSecureRestrictedImages()
-    {
-        return config('filesystems.images') === 'local_secure_restricted';
-    }
-
-    /**
-     * Change the originally provided path to fit any disk-specific requirements.
-     * This also ensures the path is kept to the expected root folders.
-     */
-    protected function adjustPathForStorageDisk(string $path, string $imageType = ''): string
-    {
-        $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/images/', '', $path));
-
-        if ($this->usingSecureImages($imageType)) {
-            return $path;
-        }
-
-        return 'uploads/images/' . $path;
-    }
-
-    /**
-     * Get the name of the storage disk to use.
-     */
-    protected function getStorageDiskName(string $imageType): string
-    {
-        $storageType = config('filesystems.images');
-        $localSecureInUse = ($storageType === 'local_secure' || $storageType === 'local_secure_restricted');
-
-        // Ensure system images (App logo) are uploaded to a public space
-        if ($imageType === 'system' && $localSecureInUse) {
-            return 'local';
-        }
-
-        // Rename local_secure options to get our image specific storage driver which
-        // is scoped to the relevant image directories.
-        if ($localSecureInUse) {
-            return 'local_secure_images';
-        }
-
-        return $storageType;
+    public function __construct(
+        protected ImageStorage $storage,
+        protected ImageResizer $resizer,
+    ) {
     }
 
     /**
      * Saves a new image from an upload.
      *
      * @throws ImageUploadException
-     *
-     * @return mixed
      */
     public function saveNewFromUpload(
         UploadedFile $uploadedFile,
@@ -117,12 +35,12 @@ class ImageService
         int $resizeWidth = null,
         int $resizeHeight = null,
         bool $keepRatio = true
-    ) {
+    ): Image {
         $imageName = $uploadedFile->getClientOriginalName();
         $imageData = file_get_contents($uploadedFile->getRealPath());
 
         if ($resizeWidth !== null || $resizeHeight !== null) {
-            $imageData = $this->resizeImage($imageData, $resizeWidth, $resizeHeight, $keepRatio);
+            $imageData = $this->resizer->resizeImageData($imageData, $resizeWidth, $resizeHeight, $keepRatio);
         }
 
         return $this->saveNew($imageName, $imageData, $type, $uploadedTo);
@@ -151,13 +69,13 @@ class ImageService
      */
     public function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
     {
-        $storage = $this->getStorageDisk($type);
+        $disk = $this->storage->getDisk($type);
         $secureUploads = setting('app-secure-images');
-        $fileName = $this->cleanImageFileName($imageName);
+        $fileName = $this->storage->cleanImageFileName($imageName);
 
         $imagePath = '/uploads/images/' . $type . '/' . date('Y-m') . '/';
 
-        while ($storage->exists($this->adjustPathForStorageDisk($imagePath . $fileName, $type))) {
+        while ($disk->exists($imagePath . $fileName)) {
             $fileName = Str::random(3) . $fileName;
         }
 
@@ -167,7 +85,7 @@ class ImageService
         }
 
         try {
-            $this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($fullPath, $type), $imageData);
+            $disk->put($fullPath, $imageData, true);
         } catch (Exception $e) {
             Log::error('Error when attempting image upload:' . $e->getMessage());
 
@@ -177,7 +95,7 @@ class ImageService
         $imageDetails = [
             'name'        => $imageName,
             'path'        => $fullPath,
-            'url'         => $this->getPublicUrl($fullPath),
+            'url'         => $this->storage->getPublicUrl($fullPath),
             'type'        => $type,
             'uploaded_to' => $uploadedTo,
         ];
@@ -194,214 +112,26 @@ 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.
-     */
-    protected function saveImageDataInPublicSpace(Storage $storage, string $path, string $data)
-    {
-        $storage->put($path, $data);
-
-        // Set visibility when a non-AWS-s3, s3-like storage option is in use.
-        // Done since this call can break s3-like services but desired for other image stores.
-        // Attempting to set ACL during above put request requires different permissions
-        // hence would technically be a breaking change for actual s3 usage.
-        $usingS3 = strtolower(config('filesystems.images')) === 's3';
-        $usingS3Like = $usingS3 && !is_null(config('filesystems.disks.s3.endpoint'));
-        if (!$usingS3Like) {
-            $storage->setVisibility($path, 'public');
-        }
-    }
-
-    /**
-     * Clean up an image file name to be both URL and storage safe.
-     */
-    protected function cleanImageFileName(string $name): string
-    {
-        $name = str_replace(' ', '-', $name);
-        $nameParts = explode('.', $name);
-        $extension = array_pop($nameParts);
-        $name = implode('-', $nameParts);
-        $name = Str::slug($name);
-
-        if (strlen($name) === 0) {
-            $name = Str::random(10);
-        }
-
-        return $name . '.' . $extension;
-    }
-
-    /**
-     * Checks if the image is a gif. Returns true if it is, else false.
-     */
-    protected function isGif(Image $image): bool
-    {
-        return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
-    }
-
-    /**
-     * Check if the given image and image data is apng.
-     */
-    protected function isApngData(Image $image, string &$imageData): bool
-    {
-        $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
-        if (!$isPng) {
-            return false;
-        }
-
-        $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
-
-        return strpos($initialHeader, 'acTL') !== false;
-    }
-
     /**
-     * Get the thumbnail for an image.
-     * If $keepRatio is true only the width will be used.
-     * Checks the cache then storage to avoid creating / accessing the filesystem on every check.
-     *
-     * @throws Exception
-     * @throws InvalidArgumentException
+     * Replace an existing image file in the system using the given file.
      */
-    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)) {
-            return $this->getPublicUrl($image->path);
-        }
-
-        $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
-        $imagePath = $image->path;
-        $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
-
-        $thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
-
-        // Return path if in cache
-        $cachedThumbPath = $this->cache->get($thumbCacheKey);
-        if ($cachedThumbPath && !$forceCreate) {
-            return $this->getPublicUrl($cachedThumbPath);
-        }
-
-        // If thumbnail has already been generated, serve that and cache path
-        $storage = $this->getStorageDisk($image->type);
-        if (!$forceCreate && $storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
-            $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
-
-            return $this->getPublicUrl($thumbFilePath);
-        }
-
-        $imageData = $storage->get($this->adjustPathForStorageDisk($imagePath, $image->type));
-
-        // Do not resize apng images where we're not cropping
-        if ($keepRatio && $this->isApngData($image, $imageData)) {
-            $this->cache->put($thumbCacheKey, $image->path, 60 * 60 * 72);
-
-            return $this->getPublicUrl($image->path);
-        }
-
-        // If not in cache and thumbnail does not exist, generate thumb and cache path
-        $thumbData = $this->resizeImage($imageData, $width, $height, $keepRatio);
-        $this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($thumbFilePath, $image->type), $thumbData);
-        $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
-
-        return $this->getPublicUrl($thumbFilePath);
-    }
-
-    /**
-     * Resize the image of given data to the specified size, and return the new image data.
-     *
-     * @throws ImageUploadException
-     */
-    protected function resizeImage(string $imageData, ?int $width, ?int $height, bool $keepRatio): string
-    {
-        try {
-            $thumb = $this->imageTool->make($imageData);
-        } catch (ErrorException | NotSupportedException $e) {
-            throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
-        }
-
-        $this->orientImageToOriginalExif($thumb, $imageData);
-
-        if ($keepRatio) {
-            $thumb->resize($width, $height, function ($constraint) {
-                $constraint->aspectRatio();
-                $constraint->upsize();
-            });
-        } else {
-            $thumb->fit($width, $height);
-        }
-
-        $thumbData = (string) $thumb->encode();
-
-        // Use original image data if we're keeping the ratio
-        // and the resizing does not save any space.
-        if ($keepRatio && strlen($thumbData) > strlen($imageData)) {
-            return $imageData;
-        }
-
-        return $thumbData;
-    }
-
-    /**
-     * Orientate the given intervention image based upon the given original image data.
-     * Intervention does have an `orientate` method but the exif data it needs is lost before it
-     * can be used (At least when created using binary string data) so we need to do some
-     * implementation on our side to use the original image data.
-     * Bulk of logic taken from: https://github.com/Intervention/image/blob/b734a4988b2148e7d10364b0609978a88d277536/src/Intervention/Image/Commands/OrientateCommand.php
-     * Copyright (c) Oliver Vogel, MIT License.
-     */
-    protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
+    public function replaceExistingFromUpload(string $path, string $type, UploadedFile $file): void
     {
-        if (!extension_loaded('exif')) {
-            return;
-        }
-
-        $stream = Utils::streamFor($originalData)->detach();
-        $exif = @exif_read_data($stream);
-        $orientation = $exif ? ($exif['Orientation'] ?? null) : null;
-
-        switch ($orientation) {
-            case 2:
-                $image->flip();
-                break;
-            case 3:
-                $image->rotate(180);
-                break;
-            case 4:
-                $image->rotate(180)->flip();
-                break;
-            case 5:
-                $image->rotate(270)->flip();
-                break;
-            case 6:
-                $image->rotate(270);
-                break;
-            case 7:
-                $image->rotate(90)->flip();
-                break;
-            case 8:
-                $image->rotate(90);
-                break;
-        }
+        $imageData = file_get_contents($file->getRealPath());
+        $disk = $this->storage->getDisk($type);
+        $disk->put($path, $imageData);
     }
 
     /**
      * Get the raw data content from an image.
      *
-     * @throws FileNotFoundException
+     * @throws Exception
      */
     public function getImageData(Image $image): string
     {
-        $storage = $this->getStorageDisk();
+        $disk = $this->storage->getDisk();
 
-        return $storage->get($this->adjustPathForStorageDisk($image->path, $image->type));
+        return $disk->get($image->path);
     }
 
     /**
@@ -409,53 +139,13 @@ class ImageService
      *
      * @throws Exception
      */
-    public function destroy(Image $image)
+    public function destroy(Image $image): void
     {
-        $this->destroyImagesFromPath($image->path, $image->type);
+        $disk = $this->storage->getDisk($image->type);
+        $disk->destroyAllMatchingNameFromPath($image->path);
         $image->delete();
     }
 
-    /**
-     * Destroys an image at the given path.
-     * Searches for image thumbnails in addition to main provided path.
-     */
-    protected function destroyImagesFromPath(string $path, string $imageType): bool
-    {
-        $path = $this->adjustPathForStorageDisk($path, $imageType);
-        $storage = $this->getStorageDisk($imageType);
-
-        $imageFolder = dirname($path);
-        $imageFileName = basename($path);
-        $allImages = collect($storage->allFiles($imageFolder));
-
-        // Delete image files
-        $imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
-            return basename($imagePath) === $imageFileName;
-        });
-        $storage->delete($imagesToDelete->all());
-
-        // Cleanup of empty folders
-        $foldersInvolved = array_merge([$imageFolder], $storage->directories($imageFolder));
-        foreach ($foldersInvolved as $directory) {
-            if ($this->isFolderEmpty($storage, $directory)) {
-                $storage->deleteDirectory($directory);
-            }
-        }
-
-        return true;
-    }
-
-    /**
-     * Check whether a folder is empty.
-     */
-    protected function isFolderEmpty(Storage $storage, string $path): bool
-    {
-        $files = $storage->files($path);
-        $folders = $storage->directories($path);
-
-        return count($files) === 0 && count($folders) === 0;
-    }
-
     /**
      * Delete gallery and drawings that are not within HTML content of pages or page revisions.
      * Checks based off of only the image name.
@@ -463,7 +153,7 @@ class ImageService
      *
      * Returns the path of the images that would be/have been deleted.
      */
-    public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = true)
+    public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = true): array
     {
         $types = ['gallery', 'drawio'];
         $deletedPaths = [];
@@ -499,36 +189,32 @@ class ImageService
      * Attempts to convert the URL to a system storage url then
      * fetch the data from the disk or storage location.
      * Returns null if the image data cannot be fetched from storage.
-     *
-     * @throws FileNotFoundException
      */
-    public function imageUriToBase64(string $uri): ?string
+    public function imageUrlToBase64(string $url): ?string
     {
-        $storagePath = $this->imageUrlToStoragePath($uri);
-        if (empty($uri) || is_null($storagePath)) {
+        $storagePath = $this->storage->urlToPath($url);
+        if (empty($url) || is_null($storagePath)) {
             return null;
         }
 
-        $storagePath = $this->adjustPathForStorageDisk($storagePath);
-
         // Apply access control when local_secure_restricted images are active
-        if ($this->usingSecureRestrictedImages()) {
+        if ($this->storage->usingSecureRestrictedImages()) {
             if (!$this->checkUserHasAccessToRelationOfImageAtPath($storagePath)) {
                 return null;
             }
         }
 
-        $storage = $this->getStorageDisk();
+        $disk = $this->storage->getDisk();
         $imageData = null;
-        if ($storage->exists($storagePath)) {
-            $imageData = $storage->get($storagePath);
+        if ($disk->exists($storagePath)) {
+            $imageData = $disk->get($storagePath);
         }
 
         if (is_null($imageData)) {
             return null;
         }
 
-        $extension = pathinfo($uri, PATHINFO_EXTENSION);
+        $extension = pathinfo($url, PATHINFO_EXTENSION);
         if ($extension === 'svg') {
             $extension = 'svg+xml';
         }
@@ -543,20 +229,18 @@ class ImageService
      */
     public function pathAccessibleInLocalSecure(string $imagePath): bool
     {
-        /** @var FilesystemAdapter $disk */
-        $disk = $this->getStorageDisk('gallery');
+        $disk = $this->storage->getDisk('gallery');
 
-        if ($this->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
+        if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
             return false;
         }
 
         // Check local_secure is active
-        return $this->usingSecureImages()
-            && $disk instanceof FilesystemAdapter
+        return $disk->usingSecureImages()
             // Check the image file exists
             && $disk->exists($imagePath)
             // Check the file is likely an image file
-            && strpos($disk->mimeType($imagePath), 'image/') === 0;
+            && str_starts_with($disk->mimeType($imagePath), 'image/');
     }
 
     /**
@@ -565,14 +249,14 @@ class ImageService
      */
     protected function checkUserHasAccessToRelationOfImageAtPath(string $path): bool
     {
-        if (strpos($path, '/uploads/images/') === 0) {
+        if (str_starts_with($path, 'uploads/images/')) {
             $path = substr($path, 15);
         }
 
         // Strip thumbnail element from path if existing
         $originalPathSplit = array_filter(explode('/', $path), function (string $part) {
-            $resizedDir = (strpos($part, 'thumbs-') === 0 || strpos($part, 'scaled-') === 0);
-            $missingExtension = strpos($part, '.') === false;
+            $resizedDir = (str_starts_with($part, 'thumbs-') || str_starts_with($part, 'scaled-'));
+            $missingExtension = !str_contains($part, '.');
 
             return !($resizedDir && $missingExtension);
         });
@@ -613,7 +297,7 @@ class ImageService
      */
     public function streamImageFromStorageResponse(string $imageType, string $path): StreamedResponse
     {
-        $disk = $this->getStorageDisk($imageType);
+        $disk = $this->storage->getDisk($imageType);
 
         return $disk->response($path);
     }
@@ -627,64 +311,4 @@ class ImageService
     {
         return in_array($extension, static::$supportedExtensions);
     }
-
-    /**
-     * Get a storage path for the given image URL.
-     * Ensures the path will start with "uploads/images".
-     * Returns null if the url cannot be resolved to a local URL.
-     */
-    private function imageUrlToStoragePath(string $url): ?string
-    {
-        $url = ltrim(trim($url), '/');
-
-        // Handle potential relative paths
-        $isRelative = strpos($url, 'http') !== 0;
-        if ($isRelative) {
-            if (strpos(strtolower($url), 'uploads/images') === 0) {
-                return trim($url, '/');
-            }
-
-            return null;
-        }
-
-        // Handle local images based on paths on the same domain
-        $potentialHostPaths = [
-            url('uploads/images/'),
-            $this->getPublicUrl('/uploads/images/'),
-        ];
-
-        foreach ($potentialHostPaths as $potentialBasePath) {
-            $potentialBasePath = strtolower($potentialBasePath);
-            if (strpos(strtolower($url), $potentialBasePath) === 0) {
-                return 'uploads/images/' . trim(substr($url, strlen($potentialBasePath)), '/');
-            }
-        }
-
-        return null;
-    }
-
-    /**
-     * Gets a public facing url for an image by checking relevant environment variables.
-     * If s3-style store is in use it will default to guessing a public bucket URL.
-     */
-    private function getPublicUrl(string $filePath): string
-    {
-        $storageUrl = config('filesystems.url');
-
-        // Get the standard public s3 url if s3 is set as storage type
-        // Uses the nice, short URL if bucket name has no periods in otherwise the longer
-        // region-based url will be used to prevent http issues.
-        if (!$storageUrl && config('filesystems.images') === 's3') {
-            $storageDetails = config('filesystems.disks.s3');
-            if (strpos($storageDetails['bucket'], '.') === false) {
-                $storageUrl = 'https://' . $storageDetails['bucket'] . '.s3.amazonaws.com';
-            } else {
-                $storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
-            }
-        }
-
-        $basePath = $storageUrl ?: url('/');
-
-        return rtrim($basePath, '/') . $filePath;
-    }
 }
diff --git a/app/Uploads/ImageStorage.php b/app/Uploads/ImageStorage.php
new file mode 100644 (file)
index 0000000..dc4abc0
--- /dev/null
@@ -0,0 +1,136 @@
+<?php
+
+namespace BookStack\Uploads;
+
+use Illuminate\Filesystem\FilesystemManager;
+use Illuminate\Support\Str;
+
+class ImageStorage
+{
+    public function __construct(
+        protected FilesystemManager $fileSystem,
+    ) {
+    }
+
+    /**
+     * Get the storage disk for the given image type.
+     */
+    public function getDisk(string $imageType = ''): ImageStorageDisk
+    {
+        $diskName = $this->getDiskName($imageType);
+
+        return new ImageStorageDisk(
+            $diskName,
+            $this->fileSystem->disk($diskName),
+        );
+    }
+
+    /**
+     * Check if "local secure restricted" (Fetched behind auth, with permissions enforced)
+     * is currently active in the instance.
+     */
+    public function usingSecureRestrictedImages(): bool
+    {
+        return config('filesystems.images') === 'local_secure_restricted';
+    }
+
+    /**
+     * Clean up an image file name to be both URL and storage safe.
+     */
+    public function cleanImageFileName(string $name): string
+    {
+        $name = str_replace(' ', '-', $name);
+        $nameParts = explode('.', $name);
+        $extension = array_pop($nameParts);
+        $name = implode('-', $nameParts);
+        $name = Str::slug($name);
+
+        if (strlen($name) === 0) {
+            $name = Str::random(10);
+        }
+
+        return $name . '.' . $extension;
+    }
+
+    /**
+     * Get the name of the storage disk to use.
+     */
+    protected function getDiskName(string $imageType): string
+    {
+        $storageType = strtolower(config('filesystems.images'));
+        $localSecureInUse = ($storageType === 'local_secure' || $storageType === 'local_secure_restricted');
+
+        // Ensure system images (App logo) are uploaded to a public space
+        if ($imageType === 'system' && $localSecureInUse) {
+            return 'local';
+        }
+
+        // Rename local_secure options to get our image specific storage driver which
+        // is scoped to the relevant image directories.
+        if ($localSecureInUse) {
+            return 'local_secure_images';
+        }
+
+        return $storageType;
+    }
+
+    /**
+     * Get a storage path for the given image URL.
+     * Ensures the path will start with "uploads/images".
+     * Returns null if the url cannot be resolved to a local URL.
+     */
+    public function urlToPath(string $url): ?string
+    {
+        $url = ltrim(trim($url), '/');
+
+        // Handle potential relative paths
+        $isRelative = !str_starts_with($url, 'http');
+        if ($isRelative) {
+            if (str_starts_with(strtolower($url), 'uploads/images')) {
+                return trim($url, '/');
+            }
+
+            return null;
+        }
+
+        // Handle local images based on paths on the same domain
+        $potentialHostPaths = [
+            url('uploads/images/'),
+            $this->getPublicUrl('/uploads/images/'),
+        ];
+
+        foreach ($potentialHostPaths as $potentialBasePath) {
+            $potentialBasePath = strtolower($potentialBasePath);
+            if (str_starts_with(strtolower($url), $potentialBasePath)) {
+                return 'uploads/images/' . trim(substr($url, strlen($potentialBasePath)), '/');
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * Gets a public facing url for an image by checking relevant environment variables.
+     * If s3-style store is in use it will default to guessing a public bucket URL.
+     */
+    public function getPublicUrl(string $filePath): string
+    {
+        $storageUrl = config('filesystems.url');
+
+        // Get the standard public s3 url if s3 is set as storage type
+        // Uses the nice, short URL if bucket name has no periods in otherwise the longer
+        // region-based url will be used to prevent http issues.
+        if (!$storageUrl && config('filesystems.images') === 's3') {
+            $storageDetails = config('filesystems.disks.s3');
+            if (!str_contains($storageDetails['bucket'], '.')) {
+                $storageUrl = 'https://' . $storageDetails['bucket'] . '.s3.amazonaws.com';
+            } else {
+                $storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
+            }
+        }
+
+        $basePath = $storageUrl ?: url('/');
+
+        return rtrim($basePath, '/') . $filePath;
+    }
+}
diff --git a/app/Uploads/ImageStorageDisk.php b/app/Uploads/ImageStorageDisk.php
new file mode 100644 (file)
index 0000000..798b72a
--- /dev/null
@@ -0,0 +1,140 @@
+<?php
+
+namespace BookStack\Uploads;
+
+use Illuminate\Contracts\Filesystem\Filesystem;
+use Illuminate\Filesystem\FilesystemAdapter;
+use League\Flysystem\WhitespacePathNormalizer;
+use Symfony\Component\HttpFoundation\StreamedResponse;
+
+class ImageStorageDisk
+{
+    public function __construct(
+        protected string $diskName,
+        protected Filesystem $filesystem,
+    ) {
+    }
+
+    /**
+     * Check if local secure image storage (Fetched behind authentication)
+     * is currently active in the instance.
+     */
+    public function usingSecureImages(): bool
+    {
+        return $this->diskName === 'local_secure_images';
+    }
+
+    /**
+     * Change the originally provided path to fit any disk-specific requirements.
+     * This also ensures the path is kept to the expected root folders.
+     */
+    protected function adjustPathForDisk(string $path): string
+    {
+        $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/images/', '', $path));
+
+        if ($this->usingSecureImages()) {
+            return $path;
+        }
+
+        return 'uploads/images/' . $path;
+    }
+
+    /**
+     * Check if a file at the given path exists.
+     */
+    public function exists(string $path): bool
+    {
+        return $this->filesystem->exists($this->adjustPathForDisk($path));
+    }
+
+    /**
+     * Get the file at the given path.
+     */
+    public function get(string $path): ?string
+    {
+        return $this->filesystem->get($this->adjustPathForDisk($path));
+    }
+
+    /**
+     * Save the given image data at the given path. Can choose to set
+     * the image as public which will update its visibility after saving.
+     */
+    public function put(string $path, string $data, bool $makePublic = false): void
+    {
+        $path = $this->adjustPathForDisk($path);
+        $this->filesystem->put($path, $data);
+
+        // Set visibility when a non-AWS-s3, s3-like storage option is in use.
+        // Done since this call can break s3-like services but desired for other image stores.
+        // Attempting to set ACL during above put request requires different permissions
+        // hence would technically be a breaking change for actual s3 usage.
+        if ($makePublic && !$this->isS3Like()) {
+            $this->filesystem->setVisibility($path, 'public');
+        }
+    }
+
+    /**
+     * Destroys an image at the given path.
+     * Searches for image thumbnails in addition to main provided path.
+     */
+    public function destroyAllMatchingNameFromPath(string $path): void
+    {
+        $path = $this->adjustPathForDisk($path);
+
+        $imageFolder = dirname($path);
+        $imageFileName = basename($path);
+        $allImages = collect($this->filesystem->allFiles($imageFolder));
+
+        // Delete image files
+        $imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
+            return basename($imagePath) === $imageFileName;
+        });
+        $this->filesystem->delete($imagesToDelete->all());
+
+        // Cleanup of empty folders
+        $foldersInvolved = array_merge([$imageFolder], $this->filesystem->directories($imageFolder));
+        foreach ($foldersInvolved as $directory) {
+            if ($this->isFolderEmpty($directory)) {
+                $this->filesystem->deleteDirectory($directory);
+            }
+        }
+    }
+
+    /**
+     * Get the mime type of the file at the given path.
+     * Only works for local filesystem adapters.
+     */
+    public function mimeType(string $path): string
+    {
+        $path = $this->adjustPathForDisk($path);
+        return $this->filesystem instanceof FilesystemAdapter ? $this->filesystem->mimeType($path) : '';
+    }
+
+    /**
+     * Get a stream response for the image at the given path.
+     */
+    public function response(string $path): StreamedResponse
+    {
+        return $this->filesystem->response($this->adjustPathForDisk($path));
+    }
+
+    /**
+     * Check if the image storage in use is an S3-like (but not likely S3) external system.
+     */
+    protected function isS3Like(): bool
+    {
+        $usingS3 = $this->diskName === 's3';
+        return $usingS3 && !is_null(config('filesystems.disks.s3.endpoint'));
+    }
+
+    /**
+     * Check whether a folder is empty.
+     */
+    protected function isFolderEmpty(string $path): bool
+    {
+        $files = $this->filesystem->files($path);
+        $folders = $this->filesystem->directories($path);
+
+        return count($files) === 0 && count($folders) === 0;
+    }
+}
index 39236c7e41b173c28bc170875716d0cb62d51816..5bd308ae87707cd425f6ba21b42748cc5a4392b7 100644 (file)
@@ -244,7 +244,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
         }
 
         try {
-            $avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default;
+            $avatar = $this->avatar?->getThumb($size, $size, false) ?? $default;
         } catch (Exception $err) {
             $avatar = $default;
         }
diff --git a/app/Util/OutOfMemoryHandler.php b/app/Util/OutOfMemoryHandler.php
new file mode 100644 (file)
index 0000000..88e9581
--- /dev/null
@@ -0,0 +1,58 @@
+<?php
+
+namespace BookStack\Util;
+
+use BookStack\Exceptions\Handler;
+use Illuminate\Contracts\Debug\ExceptionHandler;
+
+/**
+ * Create a handler which runs the provided actions upon an
+ * out-of-memory event. This allows reserving of memory to allow
+ * the desired action to run as needed.
+ *
+ * Essentially provides a wrapper and memory reserving around the
+ * memory handling added to the default app error handler.
+ */
+class OutOfMemoryHandler
+{
+    protected $onOutOfMemory;
+    protected string $memoryReserve = '';
+
+    public function __construct(callable $onOutOfMemory, int $memoryReserveMB = 4)
+    {
+        $this->onOutOfMemory = $onOutOfMemory;
+
+        $this->memoryReserve = str_repeat('x', $memoryReserveMB * 1_000_000);
+        $this->getHandler()->prepareForOutOfMemory(function () {
+            return $this->handle();
+        });
+    }
+
+    protected function handle(): mixed
+    {
+        $result = null;
+        $this->memoryReserve = '';
+
+        if ($this->onOutOfMemory) {
+            $result = call_user_func($this->onOutOfMemory);
+            $this->forget();
+        }
+
+        return $result;
+    }
+
+    /**
+     * Forget the handler so no action is taken place on out of memory.
+     */
+    public function forget(): void
+    {
+        $this->memoryReserve = '';
+        $this->onOutOfMemory = null;
+        $this->getHandler()->forgetOutOfMemoryHandler();
+    }
+
+    protected function getHandler(): Handler
+    {
+        return app()->make(ExceptionHandler::class);
+    }
+}
index 8a105096b4f6133295c637acaab526e44f4811a4..c33b1d0b7915c164799f195c385ff6c4704fe275 100644 (file)
@@ -34,6 +34,8 @@ return [
     'image_delete_success' => 'Image successfully deleted',
     'image_replace' => 'Replace Image',
     'image_replace_success' => 'Image file successfully updated',
+    'image_rebuild_thumbs' => 'Regenerate Size Variations',
+    'image_rebuild_thumbs_success' => 'Image size variations successfully rebuilt!',
 
     // Code Editor
     'code_editor' => 'Edit Code',
index 4cde4cea36fb27f8bfd1207b6732c45f317a3886..8813cf90a2da584fb01923b27c58c9e45750b825 100644 (file)
@@ -44,12 +44,16 @@ return [
     'cannot_get_image_from_url' => 'Cannot get image from :url',
     'cannot_create_thumbs' => 'The server cannot create thumbnails. Please check you have the GD PHP extension installed.',
     'server_upload_limit' => 'The server does not allow uploads of this size. Please try a smaller file size.',
+    'server_post_limit' => 'The server cannot receive the provided amount of data. Try again with less data or a smaller file.',
     'uploaded'  => 'The server does not allow uploads of this size. Please try a smaller file size.',
 
     // 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',
+    'image_upload_memory_limit' => 'Failed to handle image upload and/or create thumbnails due to system resource limits.',
+    'image_thumbnail_memory_limit' => 'Failed to create image size variations due to system resource limits.',
+    'image_gallery_thumbnail_memory_limit' => 'Failed to create gallery thumbnails due to system resource limits.',
     '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 78abcf30d8b8c5465f360094f9a9d193be02dd5e..47231477b684fc00e1d453bbc5a83d88a3188500 100644 (file)
@@ -90,6 +90,15 @@ export class ImageManager extends Component {
             }
         });
 
+        // Rebuild thumbs click
+        onChildEvent(this.formContainer, '#image-manager-rebuild-thumbs', 'click', async (_, button) => {
+            button.disabled = true;
+            if (this.lastSelected) {
+                await this.rebuildThumbnails(this.lastSelected.id);
+            }
+            button.disabled = false;
+        });
+
         // Edit form submit
         this.formContainer.addEventListener('ajax-form-success', () => {
             this.refreshGallery();
@@ -220,8 +229,8 @@ export class ImageManager extends Component {
         this.loadGallery();
     }
 
-    onImageSelectEvent(event) {
-        const image = JSON.parse(event.detail.data);
+    async onImageSelectEvent(event) {
+        let image = JSON.parse(event.detail.data);
         const isDblClick = ((image && image.id === this.lastSelected.id)
             && Date.now() - this.lastSelectedTime < 400);
         const alreadySelected = event.target.classList.contains('selected');
@@ -229,12 +238,15 @@ export class ImageManager extends Component {
             el.classList.remove('selected');
         });
 
-        if (!alreadySelected) {
+        if (!alreadySelected && !isDblClick) {
             event.target.classList.add('selected');
-            this.loadImageEditForm(image.id);
-        } else {
+            image = await this.loadImageEditForm(image.id);
+        } else if (!isDblClick) {
             this.resetEditForm();
+        } else if (isDblClick) {
+            image = this.lastSelected;
         }
+
         this.selectButton.classList.toggle('hidden', alreadySelected);
 
         if (isDblClick && this.callback) {
@@ -256,6 +268,9 @@ export class ImageManager extends Component {
         this.formContainer.innerHTML = formHtml;
         this.formContainerPlaceholder.setAttribute('hidden', '');
         window.$components.init(this.formContainer);
+
+        const imageDataEl = this.formContainer.querySelector('#image-manager-form-image-data');
+        return JSON.parse(imageDataEl.text);
     }
 
     runLoadMore() {
@@ -268,4 +283,14 @@ export class ImageManager extends Component {
         return this.loadMore.querySelector('button') && !this.loadMore.hasAttribute('hidden');
     }
 
+    async rebuildThumbnails(imageId) {
+        try {
+            const response = await window.$http.put(`/images/${imageId}/rebuild-thumbnails`);
+            window.$events.success(response.data);
+            this.refreshGallery();
+        } catch (err) {
+            window.$events.showResponseError(err);
+        }
+    }
+
 }
index a7fde9322d61b2b309e18b0015e80bafb2469869..4909a59d066f9627b3756e951be2079940369083 100644 (file)
@@ -34,7 +34,7 @@ export class Actions {
         const imageManager = window.$components.first('image-manager');
 
         imageManager.show(image => {
-            const imageUrl = image.thumbs.display || image.url;
+            const imageUrl = image.thumbs?.display || image.url;
             const selectedText = this.#getSelectionText();
             const newText = `[![${selectedText || image.name}](${imageUrl})](${image.url})`;
             this.#replaceSelection(newText, newText.length);
@@ -417,7 +417,7 @@ export class Actions {
             const newContent = `[![](${data.thumbs.display})](${data.url})`;
             this.#findAndReplaceContent(placeHolderText, newContent);
         } catch (err) {
-            window.$events.emit('error', this.editor.config.text.imageUploadError);
+            window.$events.error(err?.data?.message || this.editor.config.text.imageUploadError);
             this.#findAndReplaceContent(placeHolderText, '');
             console.error(err);
         }
index 33078cd1d5687b74fb877ef8dd2719dd4e0f59ff..9668692c81d1e162cf6f73d954c6cd307bd29735 100644 (file)
@@ -61,7 +61,7 @@ function paste(editor, options, event) {
                 editor.dom.replace(newEl, id);
             }).catch(err => {
                 editor.dom.remove(id);
-                window.$events.emit('error', options.translations.imageUploadErrorText);
+                window.$events.error(err?.data?.message || options.translations.imageUploadErrorText);
                 console.error(err);
             });
         }, 10);
index 37b5bfafd653b7f45fc523fd7b2775cbdd3e0c7c..f1ea120502a4ee99286e82fe94fc56ce5247b8d9 100644 (file)
@@ -11,7 +11,7 @@ function register(editor) {
             /** @type {ImageManager} * */
             const imageManager = window.$components.first('image-manager');
             imageManager.show(image => {
-                const imageUrl = image.thumbs.display || image.url;
+                const imageUrl = image.thumbs?.display || image.url;
                 let html = `<a href="${image.url}" target="_blank">`;
                 html += `<img src="${imageUrl}" alt="${image.name}">`;
                 html += '</a>';
index c66a432bf263ec9fde0fb084e4d685076e4364f5..150f78e12026e08e26d748e60b6854958ddf2ab0 100644 (file)
@@ -382,7 +382,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
 .image-manager-list {
   padding: 3px;
   display: grid;
-  grid-template-columns: repeat( auto-fit, minmax(140px, 1fr) );
+  grid-template-columns: repeat( auto-fill, minmax(max(140px, 17%), 1fr) );
   gap: 3px;
   z-index: 3;
   > div {
@@ -457,6 +457,18 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   text-align: center;
 }
 
+.image-manager-list .image-manager-list-warning {
+  grid-column: 1 / -1;
+  aspect-ratio: auto;
+}
+
+.image-manager-warning {
+  @include lightDark(background, #FFF, #333);
+  color: var(--color-warning);
+  font-weight: bold;
+  border-inline: 3px solid var(--color-warning);
+}
+
 .image-manager-sidebar {
   width: 300px;
   margin: 0 auto;
index 75750ef2f76373057f58d94041db194bfbda01fe..bd84e247d912d8b6a09d197f7e15082619fb98e4 100644 (file)
@@ -8,8 +8,17 @@
      option:dropzone:file-accept="image/*"
      class="image-manager-details">
 
+    @if($warning ?? '')
+        <div class="image-manager-warning px-m py-xs flex-container-row gap-xs items-center mb-l">
+            <div>@icon('warning')</div>
+            <div class="flex">{{ $warning }}</div>
+        </div>
+    @endif
+
     <div refs="dropzone@status-area dropzone@drop-target"></div>
 
+    <script id="image-manager-form-image-data" type="application/json">@json($image)</script>
+
     <form component="ajax-form"
           option:ajax-form:success-message="{{ trans('components.image_update_success') }}"
           option:ajax-form:method="put"
@@ -44,6 +53,9 @@
                                     id="image-manager-replace"
                                     refs="dropzone@select-button"
                                     class="text-item">{{ trans('components.image_replace') }}</button>
+                            <button type="button"
+                                    id="image-manager-rebuild-thumbs"
+                                    class="text-item">{{ trans('components.image_rebuild_thumbs') }}</button>
                         @endif
                     </div>
                 </div>
index 7e660c747dca135c9d947596dca6d76ace6ad1d8..ff6e23d6ac7e272a504c1b6bc074c000d2c80a48 100644 (file)
@@ -1,3 +1,9 @@
+@if($warning ?? '')
+    <div class="image-manager-list-warning image-manager-warning px-m py-xs flex-container-row gap-xs items-center">
+        <div>@icon('warning')</div>
+        <div class="flex">{{ $warning }}</div>
+    </div>
+@endif
 @foreach($images as $index => $image)
 <div>
     <button component="event-emit-select"
@@ -5,7 +11,7 @@
          option:event-emit-select:data="{{ json_encode($image) }}"
          class="image anim fadeIn text-link"
          style="animation-delay: {{ min($index * 10, 260) . 'ms' }};">
-        <img src="{{ $image->thumbs['gallery'] }}"
+        <img src="{{ $image->thumbs['gallery'] ?? '' }}"
              alt="{{ $image->name }}"
              role="none"
              width="150"
index c7fc92fc77da3bf5819dcc75cd558ce02756700d..9f5e84c62afb5161d057b1290cd945a5c16b0cb4 100644 (file)
@@ -142,6 +142,7 @@ Route::middleware('auth')->group(function () {
     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}/rebuild-thumbnails', [UploadControllers\ImageController::class, 'rebuildThumbnails']);
     Route::put('/images/{id}', [UploadControllers\ImageController::class, 'update']);
     Route::delete('/images/{id}', [UploadControllers\ImageController::class, 'destroy']);
 
index 6ba01dd88e6ed8dfb094e637f3cbf6ed1ea6cfe1..0d2ef808c48af0cfa1ed608ea2c4c6d23e3eec08 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Tests;
 
+use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
 use Illuminate\Support\Facades\Log;
 
 class ErrorTest extends TestCase
@@ -45,4 +46,16 @@ class ErrorTest extends TestCase
         $resp->assertStatus(404);
         $resp->assertSeeText('Image Not Found');
     }
+
+    public function test_posts_above_php_limit_shows_friendly_error()
+    {
+        // Fake super large JSON request
+        $resp = $this->asEditor()->call('GET', '/books', [], [], [], [
+            'CONTENT_LENGTH' => '10000000000',
+            'HTTP_ACCEPT' => 'application/json',
+        ]);
+
+        $resp->assertStatus(413);
+        $resp->assertJson(['error' => 'The server cannot receive the provided amount of data. Try again with less data or a smaller file.']);
+    }
 }
index 9e44697c7a552738464286ed98379dbc11dfcd12..99ee11efbad8b355e136a794c9b2215b8b594e18 100644 (file)
@@ -133,12 +133,21 @@ class FileProvider
      */
     public function deleteAtRelativePath(string $path): void
     {
-        $fullPath = public_path($path);
+        $fullPath = $this->relativeToFullPath($path);
         if (file_exists($fullPath)) {
             unlink($fullPath);
         }
     }
 
+    /**
+     * Convert a relative path used by default in this provider to a full
+     * absolute local filesystem path.
+     */
+    public function relativeToFullPath(string $path): string
+    {
+        return public_path($path);
+    }
+
     /**
      * Delete all uploaded files.
      * To assist with cleanup.
index a9684eef72a9e52a55897571227ddf680f11daae..4da964d4804db70e0cd75c877844ccfe46bd121b 100644 (file)
@@ -552,6 +552,30 @@ class ImageTest extends TestCase
         $this->files->deleteAtRelativePath($relPath);
     }
 
+    public function test_image_manager_regen_thumbnails()
+    {
+        $this->asEditor();
+        $imageName = 'first-image.png';
+        $relPath = $this->files->expectedImagePath('gallery', $imageName);
+        $this->files->deleteAtRelativePath($relPath);
+
+        $this->files->uploadGalleryImage($this, $imageName, $this->entities->page()->id);
+        $image = Image::first();
+
+        $resp = $this->get("/images/edit/{$image->id}");
+        $this->withHtml($resp)->assertElementExists('button#image-manager-rebuild-thumbs');
+
+        $expectedThumbPath = dirname($relPath) . '/scaled-1680-/' . basename($relPath);
+        $this->files->deleteAtRelativePath($expectedThumbPath);
+        $this->assertFileDoesNotExist($this->files->relativeToFullPath($expectedThumbPath));
+
+        $resp = $this->put("/images/{$image->id}/rebuild-thumbnails");
+        $resp->assertOk();
+
+        $this->assertFileExists($this->files->relativeToFullPath($expectedThumbPath));
+        $this->files->deleteAtRelativePath($relPath);
+    }
+
     protected function getTestProfileImage()
     {
         $imageName = 'profile.png';