class ImageController extends Controller
{
- protected ImageRepo $imageRepo;
- protected ImageService $imageService;
-
- public function __construct(ImageRepo $imageRepo, ImageService $imageService)
- {
- $this->imageRepo = $imageRepo;
- $this->imageService = $imageService;
+ public function __construct(
+ protected ImageRepo $imageRepo,
+ protected ImageService $imageService
+ ) {
}
/**
]);
}
+ /**
+ * 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.
*
class ImageRepo
{
- protected ImageService $imageService;
- protected PermissionApplicator $permissions;
-
- /**
- * ImageRepo constructor.
- */
- public function __construct(ImageService $imageService, PermissionApplicator $permissions)
- {
- $this->imageService = $imageService;
- $this->permissions = $permissions;
+ public function __construct(
+ protected ImageService $imageService,
+ protected PermissionApplicator $permissions
+ ) {
}
/**
public function updateImageDetails(Image $image, $updateDetails): Image
{
$image->fill($updateDetails);
+ $image->updated_by = user()->id;
$image->save();
$this->loadThumbs($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.
*
/**
* Load thumbnails onto an image object.
*/
- public function loadThumbs(Image $image): void
+ public function loadThumbs(Image $image, bool $forceCreate = false): void
{
$image->setAttribute('thumbs', [
- 'gallery' => $this->getThumbnail($image, 150, 150, false),
- 'display' => $this->getThumbnail($image, 1680, null, true),
+ 'gallery' => $this->getThumbnail($image, 150, 150, false, $forceCreate),
+ 'display' => $this->getThumbnail($image, 1680, null, true, $forceCreate),
]);
}
* 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): ?string
+ protected function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio, bool $forceCreate): ?string
{
try {
- return $this->imageService->getThumbnail($image, $width, $height, $keepRatio);
+ return $this->imageService->getThumbnail($image, $width, $height, $keepRatio, $forceCreate);
} catch (Exception $exception) {
return null;
}
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.
* @throws Exception
* @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
if ($keepRatio && $this->isGif($image)) {
// Return path if in cache
$cachedThumbPath = $this->cache->get($thumbCacheKey);
- if ($cachedThumbPath) {
+ if ($cachedThumbPath && !$forceCreate) {
return $this->getPublicUrl($cachedThumbPath);
}
// If thumbnail has already been generated, serve that and cache path
$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);
return $this->getPublicUrl($thumbFilePath);
'image_upload_success' => 'Image uploaded successfully',
'image_update_success' => 'Image details successfully updated',
'image_delete_success' => 'Image successfully deleted',
+ 'image_replace' => 'Replace Image',
+ 'image_replace_success' => 'Image file successfully updated',
// Code Editor
'code_editor' => 'Edit Code',
// 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',
'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
this.isActive = true;
this.url = this.$opts.url;
+ this.method = (this.$opts.method || 'post').toUpperCase();
this.successMessage = this.$opts.successMessage;
this.errorMessage = this.$opts.errorMessage;
this.uploadLimitMb = Number(this.$opts.uploadLimit);
startXhrForUpload(upload) {
const formData = new FormData();
formData.append('file', upload.file, upload.file.name);
+ if (this.method !== 'POST') {
+ formData.append('_method', this.method);
+ }
const component = this;
const req = window.$http.createXMLHttpRequest('POST', this.url, {
text-align: start !important;
max-height: 500px;
overflow-y: auto;
+ &.anchor-left {
+ inset-inline-end: auto;
+ inset-inline-start: 0;
+ }
&.wide {
min-width: 220px;
}
-<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"
option:ajax-form:success-message="{{ trans('components.image_update_success') }}"
<input id="name" class="input-base" type="text" name="name" value="{{ $image->name }}">
</div>
<div class="flex-container-row justify-space-between gap-m">
- <div>
- @if(userCan('image-delete', $image))
- <button type="button"
- id="image-manager-delete"
- title="{{ trans('common.delete') }}"
- class="button icon outline">@icon('delete')</button>
- @endif
- </div>
- <div>
+ @if(userCan('image-delete', $image) || userCan('image-update', $image))
+ <div component="dropdown"
+ class="dropdown-container">
+ <button refs="dropdown@toggle" type="button" class="button icon outline">@icon('more')</button>
+ <div refs="dropdown@menu" class="dropdown-menu anchor-left">
+ @if(userCan('image-delete', $image))
+ <button type="button"
+ id="image-manager-delete"
+ 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"
class="button icon outline">{{ trans('common.save') }}</button>
- </div>
</div>
</form>
@if(!is_null($dependantPages))
+ <hr>
@if(count($dependantPages) > 0)
<p class="text-neg mb-xs mt-m">{{ trans('components.image_delete_used') }}</p>
<ul class="text-neg">
Route::get('/images/drawio/base64/{id}', [UploadControllers\DrawioImageController::class, 'getAsBase64']);
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}', [UploadControllers\ImageController::class, 'update']);
Route::delete('/images/{id}', [UploadControllers\ImageController::class, 'destroy']);
]);
}
+ 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()
{
$this->asEditor();