diff --git a/app/Uploads/Image.php b/app/Uploads/Image.php index 5e197e750..1e42f414b 100644 --- a/app/Uploads/Image.php +++ b/app/Uploads/Image.php @@ -52,7 +52,7 @@ class Image extends Model */ public function getThumb(?int $width, ?int $height, bool $keepRatio = false): ?string { - return app()->make(ImageService::class)->getThumbnail($this, $width, $height, $keepRatio, false, true); + return app()->make(ImageResizer::class)->resizeToThumbnailUrl($this, $width, $height, $keepRatio, false, true); } /** diff --git a/app/Uploads/ImageRepo.php b/app/Uploads/ImageRepo.php index 8a770da78..4aa36bab9 100644 --- a/app/Uploads/ImageRepo.php +++ b/app/Uploads/ImageRepo.php @@ -13,7 +13,8 @@ class ImageRepo { public function __construct( protected ImageService $imageService, - protected PermissionApplicator $permissions + protected PermissionApplicator $permissions, + protected ImageResizer $imageResizer, ) { } @@ -225,14 +226,12 @@ class ImageRepo } /** - * Get the thumbnail for an image. - * If $keepRatio is true only the width will be used. - * Checks the cache then storage to avoid creating / accessing the filesystem on every check. + * Get a thumbnail URL for the given image. */ protected function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio, bool $shouldCreate): ?string { try { - return $this->imageService->getThumbnail($image, $width, $height, $keepRatio, $shouldCreate); + return $this->imageResizer->resizeToThumbnailUrl($image, $width, $height, $keepRatio, $shouldCreate); } catch (Exception $exception) { return null; } diff --git a/app/Uploads/ImageResizer.php b/app/Uploads/ImageResizer.php index 7a89b9d35..5fe8a8954 100644 --- a/app/Uploads/ImageResizer.php +++ b/app/Uploads/ImageResizer.php @@ -3,28 +3,91 @@ namespace BookStack\Uploads; use BookStack\Exceptions\ImageUploadException; +use Exception; use GuzzleHttp\Psr7\Utils; -use Intervention\Image\Exception\NotSupportedException; +use Illuminate\Support\Facades\Cache; use Intervention\Image\Image as InterventionImage; use Intervention\Image\ImageManager; class ImageResizer { public function __construct( - protected ImageManager $intervention + protected ImageManager $intervention, + protected ImageStorage $storage, ) { } + /** + * Get the thumbnail for an image. + * If $keepRatio is true only the width will be used. + * Checks the cache then storage to avoid creating / accessing the filesystem on every check. + * + * @throws Exception + */ + public function resizeToThumbnailUrl( + Image $image, + ?int $width, + ?int $height, + bool $keepRatio = false, + bool $shouldCreate = false, + bool $canCreate = false, + ): ?string { + // Do not resize GIF images where we're not cropping + if ($keepRatio && $this->isGif($image)) { + return $this->storage->getPublicUrl($image->path); + } + + $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/'; + $imagePath = $image->path; + $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath); + + $thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath; + + // Return path if in cache + $cachedThumbPath = Cache::get($thumbCacheKey); + if ($cachedThumbPath && !$shouldCreate) { + return $this->storage->getPublicUrl($cachedThumbPath); + } + + // If thumbnail has already been generated, serve that and cache path + $disk = $this->storage->getDisk($image->type); + if (!$shouldCreate && $disk->exists($thumbFilePath)) { + Cache::put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72); + + return $this->storage->getPublicUrl($thumbFilePath); + } + + $imageData = $disk->get($imagePath); + + // Do not resize apng images where we're not cropping + if ($keepRatio && $this->isApngData($image, $imageData)) { + Cache::put($thumbCacheKey, $image->path, 60 * 60 * 72); + + return $this->storage->getPublicUrl($image->path); + } + + if (!$shouldCreate && !$canCreate) { + return null; + } + + // If not in cache and thumbnail does not exist, generate thumb and cache path + $thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio); + $disk->put($thumbFilePath, $thumbData, true); + Cache::put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72); + + return $this->storage->getPublicUrl($thumbFilePath); + } + /** * Resize the image of given data to the specified size, and return the new image data. * * @throws ImageUploadException */ - protected function resizeImageData(string $imageData, ?int $width, ?int $height, bool $keepRatio): string + public function resizeImageData(string $imageData, ?int $width, ?int $height, bool $keepRatio): string { try { $thumb = $this->intervention->make($imageData); - } catch (NotSupportedException $e) { + } catch (Exception $e) { throw new ImageUploadException(trans('errors.cannot_create_thumbs')); } @@ -92,4 +155,27 @@ class ImageResizer break; } } + + /** + * Checks if the image is a gif. Returns true if it is, else false. + */ + protected function isGif(Image $image): bool + { + return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif'; + } + + /** + * Check if the given image and image data is apng. + */ + protected function isApngData(Image $image, string &$imageData): bool + { + $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png'; + if (!$isPng) { + return false; + } + + $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT')); + + return str_contains($initialHeader, 'acTL'); + } } diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php index f8567c3e5..1655a4cc3 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -6,15 +6,10 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Page; use BookStack\Exceptions\ImageUploadException; -use ErrorException; use Exception; -use Illuminate\Contracts\Cache\Repository as Cache; -use Illuminate\Filesystem\FilesystemManager; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; -use Intervention\Image\Exception\NotSupportedException; -use Intervention\Image\ImageManager; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -23,10 +18,8 @@ class ImageService protected static array $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp']; public function __construct( - protected ImageManager $imageTool, - protected FilesystemManager $fileSystem, - protected Cache $cache, protected ImageStorage $storage, + protected ImageResizer $resizer, ) { } @@ -47,7 +40,7 @@ class ImageService $imageData = file_get_contents($uploadedFile->getRealPath()); if ($resizeWidth !== null || $resizeHeight !== null) { - $imageData = $this->resizeImage($imageData, $resizeWidth, $resizeHeight, $keepRatio); + $imageData = $this->resizer->resizeImageData($imageData, $resizeWidth, $resizeHeight, $keepRatio); } return $this->saveNew($imageName, $imageData, $type, $uploadedTo); @@ -129,125 +122,6 @@ class ImageService $disk->put($path, $imageData); } - /** - * Checks if the image is a gif. Returns true if it is, else false. - */ - protected function isGif(Image $image): bool - { - return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif'; - } - - /** - * Check if the given image and image data is apng. - */ - protected function isApngData(Image $image, string &$imageData): bool - { - $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png'; - if (!$isPng) { - return false; - } - - $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT')); - - return str_contains($initialHeader, 'acTL'); - } - - /** - * Get the thumbnail for an image. - * If $keepRatio is true only the width will be used. - * Checks the cache then storage to avoid creating / accessing the filesystem on every check. - * - * @throws Exception - */ - public function getThumbnail( - Image $image, - ?int $width, - ?int $height, - bool $keepRatio = false, - bool $shouldCreate = false, - bool $canCreate = false, - ): ?string { - // Do not resize GIF images where we're not cropping - if ($keepRatio && $this->isGif($image)) { - return $this->storage->getPublicUrl($image->path); - } - - $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/'; - $imagePath = $image->path; - $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath); - - $thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath; - - // Return path if in cache - $cachedThumbPath = $this->cache->get($thumbCacheKey); - if ($cachedThumbPath && !$shouldCreate) { - return $this->storage->getPublicUrl($cachedThumbPath); - } - - // If thumbnail has already been generated, serve that and cache path - $disk = $this->storage->getDisk($image->type); - if (!$shouldCreate && $disk->exists($thumbFilePath)) { - $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72); - - return $this->storage->getPublicUrl($thumbFilePath); - } - - $imageData = $disk->get($imagePath); - - // Do not resize apng images where we're not cropping - if ($keepRatio && $this->isApngData($image, $imageData)) { - $this->cache->put($thumbCacheKey, $image->path, 60 * 60 * 72); - - return $this->storage->getPublicUrl($image->path); - } - - if (!$shouldCreate && !$canCreate) { - return null; - } - - // If not in cache and thumbnail does not exist, generate thumb and cache path - $thumbData = $this->resizeImage($imageData, $width, $height, $keepRatio); - $disk->put($thumbFilePath, $thumbData, true); - $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72); - - return $this->storage->getPublicUrl($thumbFilePath); - } - - /** - * Resize the image of given data to the specified size, and return the new image data. - * - * @throws ImageUploadException - */ - protected function resizeImage(string $imageData, ?int $width, ?int $height, bool $keepRatio): string - { - try { - $thumb = $this->imageTool->make($imageData); - } catch (ErrorException | NotSupportedException $e) { - throw new ImageUploadException(trans('errors.cannot_create_thumbs')); - } - - $this->orientImageToOriginalExif($thumb, $imageData); - - if ($keepRatio) { - $thumb->resize($width, $height, function ($constraint) { - $constraint->aspectRatio(); - $constraint->upsize(); - }); - } else { - $thumb->fit($width, $height); - } - - $thumbData = (string) $thumb->encode(); - - // Use original image data if we're keeping the ratio - // and the resizing does not save any space. - if ($keepRatio && strlen($thumbData) > strlen($imageData)) { - return $imageData; - } - - return $thumbData; - } - /** * Get the raw data content from an image. * @@ -375,7 +249,7 @@ class ImageService */ protected function checkUserHasAccessToRelationOfImageAtPath(string $path): bool { - if (str_starts_with($path, '/uploads/images/')) { + if (str_starts_with($path, 'uploads/images/')) { $path = substr($path, 15); } diff --git a/app/Uploads/ImageStorageDisk.php b/app/Uploads/ImageStorageDisk.php index 3a95661ca..798b72abd 100644 --- a/app/Uploads/ImageStorageDisk.php +++ b/app/Uploads/ImageStorageDisk.php @@ -50,7 +50,7 @@ class ImageStorageDisk /** * Get the file at the given path. */ - public function get(string $path): bool + public function get(string $path): ?string { return $this->filesystem->get($this->adjustPathForDisk($path)); } @@ -106,6 +106,7 @@ class ImageStorageDisk */ public function mimeType(string $path): string { + $path = $this->adjustPathForDisk($path); return $this->filesystem instanceof FilesystemAdapter ? $this->filesystem->mimeType($path) : ''; } @@ -114,7 +115,7 @@ class ImageStorageDisk */ public function response(string $path): StreamedResponse { - return $this->filesystem->response($path); + return $this->filesystem->response($this->adjustPathForDisk($path)); } /** diff --git a/tests/Uploads/ImageTest.php b/tests/Uploads/ImageTest.php index 9943302d3..4da964d48 100644 --- a/tests/Uploads/ImageTest.php +++ b/tests/Uploads/ImageTest.php @@ -557,6 +557,7 @@ class ImageTest extends TestCase $this->asEditor(); $imageName = 'first-image.png'; $relPath = $this->files->expectedImagePath('gallery', $imageName); + $this->files->deleteAtRelativePath($relPath); $this->files->uploadGalleryImage($this, $imageName, $this->entities->page()->id); $image = Image::first();