mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-04-23 12:20:21 +00:00
Added the ability to replace existing image files
- Updated UI with image form dropdown containing delete and replace image actions. - Adds new endpoint and service/repo handling for replacing existing image. - Includes tests to cover.
This commit is contained in:
parent
9ff7c97911
commit
e3c4a9d167
10 changed files with 146 additions and 36 deletions
app/Uploads
lang/en
resources
routes
tests/Uploads
|
@ -14,13 +14,10 @@ use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class ImageController extends Controller
|
class ImageController extends Controller
|
||||||
{
|
{
|
||||||
protected ImageRepo $imageRepo;
|
public function __construct(
|
||||||
protected ImageService $imageService;
|
protected ImageRepo $imageRepo,
|
||||||
|
protected ImageService $imageService
|
||||||
public function __construct(ImageRepo $imageRepo, ImageService $imageService)
|
) {
|
||||||
{
|
|
||||||
$this->imageRepo = $imageRepo;
|
|
||||||
$this->imageService = $imageService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -65,6 +62,29 @@ class ImageController extends Controller
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the file for an existing image.
|
||||||
|
*/
|
||||||
|
public function updateFile(Request $request, string $id)
|
||||||
|
{
|
||||||
|
$this->validate($request, [
|
||||||
|
'file' => ['required', 'file', ...$this->getImageValidationRules()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$image = $this->imageRepo->getById($id);
|
||||||
|
$this->checkImagePermission($image);
|
||||||
|
$this->checkOwnablePermission('image-update', $image);
|
||||||
|
$file = $request->file('file');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->imageRepo->updateImageFile($image, $file);
|
||||||
|
} catch (ImageUploadException $exception) {
|
||||||
|
return $this->jsonError($exception->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return response('');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the form for editing the given image.
|
* Get the form for editing the given image.
|
||||||
*
|
*
|
||||||
|
|
|
@ -11,16 +11,10 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
|
||||||
class ImageRepo
|
class ImageRepo
|
||||||
{
|
{
|
||||||
protected ImageService $imageService;
|
public function __construct(
|
||||||
protected PermissionApplicator $permissions;
|
protected ImageService $imageService,
|
||||||
|
protected PermissionApplicator $permissions
|
||||||
/**
|
) {
|
||||||
* ImageRepo constructor.
|
|
||||||
*/
|
|
||||||
public function __construct(ImageService $imageService, PermissionApplicator $permissions)
|
|
||||||
{
|
|
||||||
$this->imageService = $imageService;
|
|
||||||
$this->permissions = $permissions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -164,12 +158,29 @@ class ImageRepo
|
||||||
public function updateImageDetails(Image $image, $updateDetails): Image
|
public function updateImageDetails(Image $image, $updateDetails): Image
|
||||||
{
|
{
|
||||||
$image->fill($updateDetails);
|
$image->fill($updateDetails);
|
||||||
|
$image->updated_by = user()->id;
|
||||||
$image->save();
|
$image->save();
|
||||||
$this->loadThumbs($image);
|
$this->loadThumbs($image);
|
||||||
|
|
||||||
return $image;
|
return $image;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the image file of an existing image in the system.
|
||||||
|
* @throws ImageUploadException
|
||||||
|
*/
|
||||||
|
public function updateImageFile(Image $image, UploadedFile $file): void
|
||||||
|
{
|
||||||
|
if ($file->getClientOriginalExtension() !== pathinfo($image->path, PATHINFO_EXTENSION)) {
|
||||||
|
throw new ImageUploadException(trans('errors.image_upload_replace_type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$image->updated_by = user()->id;
|
||||||
|
$image->save();
|
||||||
|
$this->imageService->replaceExistingFromUpload($image->path, $image->type, $file);
|
||||||
|
$this->loadThumbs($image, true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Destroys an Image object along with its revisions, files and thumbnails.
|
* Destroys an Image object along with its revisions, files and thumbnails.
|
||||||
*
|
*
|
||||||
|
@ -202,11 +213,11 @@ class ImageRepo
|
||||||
/**
|
/**
|
||||||
* Load thumbnails onto an image object.
|
* Load thumbnails onto an image object.
|
||||||
*/
|
*/
|
||||||
public function loadThumbs(Image $image): void
|
public function loadThumbs(Image $image, bool $forceCreate = false): void
|
||||||
{
|
{
|
||||||
$image->setAttribute('thumbs', [
|
$image->setAttribute('thumbs', [
|
||||||
'gallery' => $this->getThumbnail($image, 150, 150, false),
|
'gallery' => $this->getThumbnail($image, 150, 150, false, $forceCreate),
|
||||||
'display' => $this->getThumbnail($image, 1680, null, true),
|
'display' => $this->getThumbnail($image, 1680, null, true, $forceCreate),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -215,10 +226,10 @@ class ImageRepo
|
||||||
* If $keepRatio is true only the width will be used.
|
* If $keepRatio is true only the width will be used.
|
||||||
* Checks the cache then storage to avoid creating / accessing the filesystem on every check.
|
* Checks the cache then storage to avoid creating / accessing the filesystem on every check.
|
||||||
*/
|
*/
|
||||||
protected function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio): ?string
|
protected function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio, bool $forceCreate): ?string
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
return $this->imageService->getThumbnail($image, $width, $height, $keepRatio);
|
return $this->imageService->getThumbnail($image, $width, $height, $keepRatio, $forceCreate);
|
||||||
} catch (Exception $exception) {
|
} catch (Exception $exception) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -194,6 +194,14 @@ class ImageService
|
||||||
return $image;
|
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,
|
* Save image data for the given path in the public space, if possible,
|
||||||
* for the provided storage mechanism.
|
* for the provided storage mechanism.
|
||||||
|
@ -262,7 +270,7 @@ class ImageService
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
* @throws InvalidArgumentException
|
* @throws InvalidArgumentException
|
||||||
*/
|
*/
|
||||||
public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false): string
|
public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false, bool $forceCreate = false): string
|
||||||
{
|
{
|
||||||
// Do not resize GIF images where we're not cropping
|
// Do not resize GIF images where we're not cropping
|
||||||
if ($keepRatio && $this->isGif($image)) {
|
if ($keepRatio && $this->isGif($image)) {
|
||||||
|
@ -277,13 +285,13 @@ class ImageService
|
||||||
|
|
||||||
// Return path if in cache
|
// Return path if in cache
|
||||||
$cachedThumbPath = $this->cache->get($thumbCacheKey);
|
$cachedThumbPath = $this->cache->get($thumbCacheKey);
|
||||||
if ($cachedThumbPath) {
|
if ($cachedThumbPath && !$forceCreate) {
|
||||||
return $this->getPublicUrl($cachedThumbPath);
|
return $this->getPublicUrl($cachedThumbPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If thumbnail has already been generated, serve that and cache path
|
// If thumbnail has already been generated, serve that and cache path
|
||||||
$storage = $this->getStorageDisk($image->type);
|
$storage = $this->getStorageDisk($image->type);
|
||||||
if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
|
if (!$forceCreate && $storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
|
||||||
$this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
|
$this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
|
||||||
|
|
||||||
return $this->getPublicUrl($thumbFilePath);
|
return $this->getPublicUrl($thumbFilePath);
|
||||||
|
|
|
@ -32,6 +32,8 @@ return [
|
||||||
'image_upload_success' => 'Image uploaded successfully',
|
'image_upload_success' => 'Image uploaded successfully',
|
||||||
'image_update_success' => 'Image details successfully updated',
|
'image_update_success' => 'Image details successfully updated',
|
||||||
'image_delete_success' => 'Image successfully deleted',
|
'image_delete_success' => 'Image successfully deleted',
|
||||||
|
'image_replace' => 'Replace Image',
|
||||||
|
'image_replace_success' => 'Image file successfully updated',
|
||||||
|
|
||||||
// Code Editor
|
// Code Editor
|
||||||
'code_editor' => 'Edit Code',
|
'code_editor' => 'Edit Code',
|
||||||
|
|
|
@ -49,6 +49,7 @@ return [
|
||||||
// Drawing & Images
|
// Drawing & Images
|
||||||
'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',
|
||||||
'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
|
||||||
|
|
|
@ -15,6 +15,7 @@ export class Dropzone extends Component {
|
||||||
this.isActive = true;
|
this.isActive = true;
|
||||||
|
|
||||||
this.url = this.$opts.url;
|
this.url = this.$opts.url;
|
||||||
|
this.method = (this.$opts.method || 'post').toUpperCase();
|
||||||
this.successMessage = this.$opts.successMessage;
|
this.successMessage = this.$opts.successMessage;
|
||||||
this.errorMessage = this.$opts.errorMessage;
|
this.errorMessage = this.$opts.errorMessage;
|
||||||
this.uploadLimitMb = Number(this.$opts.uploadLimit);
|
this.uploadLimitMb = Number(this.$opts.uploadLimit);
|
||||||
|
@ -167,6 +168,9 @@ export class Dropzone extends Component {
|
||||||
startXhrForUpload(upload) {
|
startXhrForUpload(upload) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', upload.file, upload.file.name);
|
formData.append('file', upload.file, upload.file.name);
|
||||||
|
if (this.method !== 'POST') {
|
||||||
|
formData.append('_method', this.method);
|
||||||
|
}
|
||||||
const component = this;
|
const component = this;
|
||||||
|
|
||||||
const req = window.$http.createXMLHttpRequest('POST', this.url, {
|
const req = window.$http.createXMLHttpRequest('POST', this.url, {
|
||||||
|
|
|
@ -674,6 +674,10 @@ ul.pagination {
|
||||||
text-align: start !important;
|
text-align: start !important;
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
&.anchor-left {
|
||||||
|
inset-inline-end: auto;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
}
|
||||||
&.wide {
|
&.wide {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,14 @@
|
||||||
<div class="image-manager-details">
|
<div component="dropzone"
|
||||||
|
option:dropzone:url="{{ url("/images/{$image->id}/file") }}"
|
||||||
|
option:dropzone:method="PUT"
|
||||||
|
option:dropzone:success-message="{{ trans('components.image_update_success') }}"
|
||||||
|
option:dropzone:upload-limit="{{ config('app.upload_limit') }}"
|
||||||
|
option:dropzone:upload-limit-message="{{ trans('errors.server_upload_limit') }}"
|
||||||
|
option:dropzone:zone-text="{{ trans('entities.attachments_dropzone') }}"
|
||||||
|
option:dropzone:file-accept="image/*"
|
||||||
|
class="image-manager-details">
|
||||||
|
|
||||||
|
<div refs="dropzone@status-area dropzone@drop-target"></div>
|
||||||
|
|
||||||
<form component="ajax-form"
|
<form component="ajax-form"
|
||||||
option:ajax-form:success-message="{{ trans('components.image_update_success') }}"
|
option:ajax-form:success-message="{{ trans('components.image_update_success') }}"
|
||||||
|
@ -19,22 +29,32 @@
|
||||||
<input id="name" class="input-base" type="text" name="name" value="{{ $image->name }}">
|
<input id="name" class="input-base" type="text" name="name" value="{{ $image->name }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-container-row justify-space-between gap-m">
|
<div class="flex-container-row justify-space-between gap-m">
|
||||||
<div>
|
@if(userCan('image-delete', $image) || userCan('image-update', $image))
|
||||||
@if(userCan('image-delete', $image))
|
<div component="dropdown"
|
||||||
<button type="button"
|
class="dropdown-container">
|
||||||
id="image-manager-delete"
|
<button refs="dropdown@toggle" type="button" class="button icon outline">@icon('more')</button>
|
||||||
title="{{ trans('common.delete') }}"
|
<div refs="dropdown@menu" class="dropdown-menu anchor-left">
|
||||||
class="button icon outline">@icon('delete')</button>
|
@if(userCan('image-delete', $image))
|
||||||
@endif
|
<button type="button"
|
||||||
</div>
|
id="image-manager-delete"
|
||||||
<div>
|
class="text-item">{{ trans('common.delete') }}</button>
|
||||||
|
@endif
|
||||||
|
@if(userCan('image-update', $image))
|
||||||
|
<button type="button"
|
||||||
|
id="image-manager-replace"
|
||||||
|
refs="dropzone@select-button"
|
||||||
|
class="text-item">{{ trans('components.image_replace') }}</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="button icon outline">{{ trans('common.save') }}</button>
|
class="button icon outline">{{ trans('common.save') }}</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@if(!is_null($dependantPages))
|
@if(!is_null($dependantPages))
|
||||||
|
<hr>
|
||||||
@if(count($dependantPages) > 0)
|
@if(count($dependantPages) > 0)
|
||||||
<p class="text-neg mb-xs mt-m">{{ trans('components.image_delete_used') }}</p>
|
<p class="text-neg mb-xs mt-m">{{ trans('components.image_delete_used') }}</p>
|
||||||
<ul class="text-neg">
|
<ul class="text-neg">
|
||||||
|
|
|
@ -140,6 +140,7 @@ Route::middleware('auth')->group(function () {
|
||||||
Route::get('/images/drawio/base64/{id}', [UploadControllers\DrawioImageController::class, 'getAsBase64']);
|
Route::get('/images/drawio/base64/{id}', [UploadControllers\DrawioImageController::class, 'getAsBase64']);
|
||||||
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}', [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']);
|
||||||
|
|
||||||
|
|
|
@ -92,6 +92,45 @@ class ImageTest extends TestCase
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_image_file_update()
|
||||||
|
{
|
||||||
|
$page = $this->entities->page();
|
||||||
|
$this->asEditor();
|
||||||
|
|
||||||
|
$imgDetails = $this->files->uploadGalleryImageToPage($this, $page);
|
||||||
|
$relPath = $imgDetails['path'];
|
||||||
|
|
||||||
|
$newUpload = $this->files->uploadedImage('updated-image.png', 'compressed.png');
|
||||||
|
$this->assertFileEquals($this->files->testFilePath('test-image.png'), public_path($relPath));
|
||||||
|
|
||||||
|
$imageId = $imgDetails['response']->id;
|
||||||
|
$this->call('PUT', "/images/{$imageId}/file", [], [], ['file' => $newUpload])
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
$this->assertFileEquals($this->files->testFilePath('compressed.png'), public_path($relPath));
|
||||||
|
|
||||||
|
$this->files->deleteAtRelativePath($relPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_image_file_update_does_not_allow_change_in_image_extension()
|
||||||
|
{
|
||||||
|
$page = $this->entities->page();
|
||||||
|
$this->asEditor();
|
||||||
|
|
||||||
|
$imgDetails = $this->files->uploadGalleryImageToPage($this, $page);
|
||||||
|
$relPath = $imgDetails['path'];
|
||||||
|
$newUpload = $this->files->uploadedImage('updated-image.jpg', 'compressed.png');
|
||||||
|
|
||||||
|
$imageId = $imgDetails['response']->id;
|
||||||
|
$this->call('PUT', "/images/{$imageId}/file", [], [], ['file' => $newUpload])
|
||||||
|
->assertJson([
|
||||||
|
"message" => "Image file replacements must be of the same type",
|
||||||
|
"status" => "error",
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->files->deleteAtRelativePath($relPath);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_gallery_get_list_format()
|
public function test_gallery_get_list_format()
|
||||||
{
|
{
|
||||||
$this->asEditor();
|
$this->asEditor();
|
||||||
|
|
Loading…
Add table
Reference in a new issue