From 5af3041b9be351870cbe0c1eab82d7fa61fe0035 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 29 Sep 2023 13:54:08 +0100 Subject: [PATCH] Thumbnails: Added OOM handling and regen endpoint - 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 | 35 +++++++++++ app/Uploads/Controllers/ImageController.php | 19 ++++++ app/Util/OutOfMemoryHandler.php | 58 +++++++++++++++++++ lang/en/components.php | 2 + lang/en/errors.php | 1 + resources/js/components/image-manager.js | 19 ++++++ .../pages/parts/image-manager-form.blade.php | 3 + routes/web.php | 1 + 8 files changed, 138 insertions(+) create mode 100644 app/Util/OutOfMemoryHandler.php diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 385720643..6a4420056 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -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 + */ + 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. */ diff --git a/app/Uploads/Controllers/ImageController.php b/app/Uploads/Controllers/ImageController.php index 0f88b376e..edf1533fa 100644 --- a/app/Uploads/Controllers/ImageController.php +++ b/app/Uploads/Controllers/ImageController.php @@ -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 index 000000000..88e9581f4 --- /dev/null +++ b/app/Util/OutOfMemoryHandler.php @@ -0,0 +1,58 @@ +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); + } +} diff --git a/lang/en/components.php b/lang/en/components.php index 8a105096b..c33b1d0b7 100644 --- a/lang/en/components.php +++ b/lang/en/components.php @@ -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', diff --git a/lang/en/errors.php b/lang/en/errors.php index 1f7404697..4164d558b 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -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 diff --git a/resources/js/components/image-manager.js b/resources/js/components/image-manager.js index 78abcf30d..bc0493a88 100644 --- a/resources/js/components/image-manager.js +++ b/resources/js/components/image-manager.js @@ -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); + } + } + } diff --git a/resources/views/pages/parts/image-manager-form.blade.php b/resources/views/pages/parts/image-manager-form.blade.php index 75750ef2f..3a73bee7c 100644 --- a/resources/views/pages/parts/image-manager-form.blade.php +++ b/resources/views/pages/parts/image-manager-form.blade.php @@ -44,6 +44,9 @@ id="image-manager-replace" refs="dropzone@select-button" class="text-item">{{ trans('components.image_replace') }} + @endif diff --git a/routes/web.php b/routes/web.php index c7fc92fc7..9f5e84c62 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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']);