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.
This commit is contained in:
Dan Brown 2023-09-29 13:54:08 +01:00
parent cc0827ff28
commit 5af3041b9b
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
8 changed files with 138 additions and 0 deletions

View File

@ -10,6 +10,7 @@ use Illuminate\Http\Exceptions\PostTooLargeException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Symfony\Component\ErrorHandler\Error\FatalError;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Throwable; use Throwable;
@ -36,6 +37,15 @@ class Handler extends ExceptionHandler
'password_confirmation', '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. * Report or log an exception.
* *
@ -60,6 +70,13 @@ class Handler extends ExceptionHandler
*/ */
public function render($request, Throwable $e) 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) { if ($e instanceof PostTooLargeException) {
$e = new NotifyException(trans('errors.server_post_limit'), '/', 413); $e = new NotifyException(trans('errors.server_post_limit'), '/', 413);
} }
@ -71,6 +88,24 @@ class Handler extends ExceptionHandler
return parent::render($request, $e); 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. * Check if the given request is an API request.
*/ */

View File

@ -8,6 +8,7 @@ use BookStack\Http\Controller;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageService; use BookStack\Uploads\ImageService;
use BookStack\Util\OutOfMemoryHandler;
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -121,6 +122,24 @@ class ImageController extends Controller
return response(''); 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. * Check related page permission and ensure type is drawio or gallery.
*/ */

View File

@ -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);
}
}

View File

@ -34,6 +34,8 @@ return [
'image_delete_success' => 'Image successfully deleted', 'image_delete_success' => 'Image successfully deleted',
'image_replace' => 'Replace Image', 'image_replace' => 'Replace Image',
'image_replace_success' => 'Image file successfully updated', '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
'code_editor' => 'Edit Code', 'code_editor' => 'Edit Code',

View File

@ -51,6 +51,7 @@ return [
'image_upload_error' => 'An error occurred uploading the image', 'image_upload_error' => 'An error occurred uploading the image',
'image_upload_type_error' => 'The image type being uploaded is invalid', '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_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.', '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 // Attachments

View 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 // Edit form submit
this.formContainer.addEventListener('ajax-form-success', () => { this.formContainer.addEventListener('ajax-form-success', () => {
this.refreshGallery(); this.refreshGallery();
@ -268,4 +277,14 @@ export class ImageManager extends Component {
return this.loadMore.querySelector('button') && !this.loadMore.hasAttribute('hidden'); 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);
}
}
} }

View File

@ -44,6 +44,9 @@
id="image-manager-replace" id="image-manager-replace"
refs="dropzone@select-button" refs="dropzone@select-button"
class="text-item">{{ trans('components.image_replace') }}</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 @endif
</div> </div>
</div> </div>

View File

@ -142,6 +142,7 @@ Route::middleware('auth')->group(function () {
Route::post('/images/drawio', [UploadControllers\DrawioImageController::class, 'create']); Route::post('/images/drawio', [UploadControllers\DrawioImageController::class, 'create']);
Route::get('/images/edit/{id}', [UploadControllers\ImageController::class, 'edit']); Route::get('/images/edit/{id}', [UploadControllers\ImageController::class, 'edit']);
Route::put('/images/{id}/file', [UploadControllers\ImageController::class, 'updateFile']); 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::put('/images/{id}', [UploadControllers\ImageController::class, 'update']);
Route::delete('/images/{id}', [UploadControllers\ImageController::class, 'destroy']); Route::delete('/images/{id}', [UploadControllers\ImageController::class, 'destroy']);