]> BookStack Code Mirror - bookstack/commitdiff
Thumbnails: Added OOM handling and regen endpoint
authorDan Brown <redacted>
Fri, 29 Sep 2023 12:54:08 +0000 (13:54 +0100)
committerDan Brown <redacted>
Fri, 29 Sep 2023 12:54:08 +0000 (13:54 +0100)
- Added some level of app out-of-memory handling so we can show a proper
  error message upon OOM events.
- Added endpoint and image-manager button/action for regenerating
  thumbnails for an image so they can be re-created upon failure.

app/Exceptions/Handler.php
app/Uploads/Controllers/ImageController.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/views/pages/parts/image-manager-form.blade.php
routes/web.php

index 38572064343b84eba1923908694ca7f2f8ee1e57..6a44200568c96db4eeed5b71c3ffcf480a1eba39 100644 (file)
@@ -10,6 +10,7 @@ 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;
 
@@ -36,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.
      *
@@ -60,6 +70,13 @@ 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);
         }
@@ -71,6 +88,24 @@ 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.
      */
index 0f88b376e519f9760743853301c8d347e9136294..edf1533fad7d8a81c41b6bd5691409486c7dc945 100644 (file)
@@ -8,6 +8,7 @@ use BookStack\Http\Controller;
 use BookStack\Uploads\Image;
 use BookStack\Uploads\ImageRepo;
 use BookStack\Uploads\ImageService;
+use BookStack\Util\OutOfMemoryHandler;
 use Exception;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
@@ -121,6 +122,24 @@ 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->imageRepo->loadThumbs($image, true);
+
+        return response(trans('components.image_rebuild_thumbs_success'));
+    }
+
     /**
      * Check related page permission and ensure type is drawio or gallery.
      */
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 1f74046970897a72e8a5df53b2dab2d793ac4aaa..4164d558b6a1cbb371825c20b8bd0800de2de276 100644 (file)
@@ -51,6 +51,7 @@ return [
     '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_thumbnail_memory_limit' => 'Failed to create image size variations 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..bc0493a88faddac4d1f400575285802cb86eba7e 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();
@@ -268,4 +277,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 75750ef2f76373057f58d94041db194bfbda01fe..3a73bee7c0993131a9a6399f4d4cc1b8f010d7f1 100644 (file)
@@ -44,6 +44,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 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']);