X-Git-Url: http://source.bookstackapp.com/bookstack/blobdiff_plain/7247e31936ebf630b28be5870a5760be920b0d90..refs/pull/5721/head:/app/Uploads/ImageService.php diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php index 81d6add92..a8f144517 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -2,34 +2,23 @@ namespace BookStack\Uploads; -use BookStack\Entities\Models\Book; -use BookStack\Entities\Models\Bookshelf; -use BookStack\Entities\Models\Page; +use BookStack\Entities\Queries\EntityQueries; use BookStack\Exceptions\ImageUploadException; -use ErrorException; use Exception; -use Illuminate\Contracts\Cache\Repository as Cache; -use Illuminate\Contracts\Filesystem\FileNotFoundException; -use Illuminate\Contracts\Filesystem\Filesystem as StorageDisk; -use Illuminate\Filesystem\FilesystemAdapter; -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; class ImageService { - protected static array $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp']; + protected static array $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif']; public function __construct( - protected ImageManager $imageTool, - protected FilesystemManager $fileSystem, - protected Cache $cache, protected ImageStorage $storage, + protected ImageResizer $resizer, + protected EntityQueries $queries, ) { } @@ -42,15 +31,16 @@ class ImageService UploadedFile $uploadedFile, string $type, int $uploadedTo = 0, - int $resizeWidth = null, - int $resizeHeight = null, - bool $keepRatio = true + ?int $resizeWidth = null, + ?int $resizeHeight = null, + bool $keepRatio = true, + string $imageName = '', ): Image { - $imageName = $uploadedFile->getClientOriginalName(); + $imageName = $imageName ?: $uploadedFile->getClientOriginalName(); $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); @@ -85,7 +75,7 @@ class ImageService $imagePath = '/uploads/images/' . $type . '/' . date('Y-m') . '/'; - while ($disk->exists($this->storage->adjustPathForDisk($imagePath . $fileName, $type))) { + while ($disk->exists($imagePath . $fileName)) { $fileName = Str::random(3) . $fileName; } @@ -95,7 +85,7 @@ class ImageService } try { - $this->storage->storeInPublicSpace($disk, $this->storage->adjustPathForDisk($fullPath, $type), $imageData); + $disk->put($fullPath, $imageData, true); } catch (Exception $e) { Log::error('Error when attempting image upload:' . $e->getMessage()); @@ -129,141 +119,32 @@ class ImageService { $imageData = file_get_contents($file->getRealPath()); $disk = $this->storage->getDisk($type); - $adjustedPath = $this->storage->adjustPathForDisk($path, $type); - $disk->put($adjustedPath, $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'); + $disk->put($path, $imageData); } /** - * 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 the raw data content from an image. * * @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($this->storage->adjustPathForDisk($thumbFilePath, $image->type))) { - $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72); - - return $this->storage->getPublicUrl($thumbFilePath); - } - - $imageData = $disk->get($this->storage->adjustPathForDisk($imagePath, $image->type)); - - // 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); - $this->storage->storeInPublicSpace($disk, $this->storage->adjustPathForDisk($thumbFilePath, $image->type), $thumbData); - $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 + public function getImageData(Image $image): 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; - } + $disk = $this->storage->getDisk(); - return $thumbData; + return $disk->get($image->path); } - /** * Get the raw data content from an image. * * @throws Exception + * @returns ?resource */ - public function getImageData(Image $image): string + public function getImageStream(Image $image): mixed { $disk = $this->storage->getDisk(); - return $disk->get($this->storage->adjustPathForDisk($image->path, $image->type)); + return $disk->stream($image->path); } /** @@ -271,51 +152,19 @@ class ImageService * * @throws Exception */ - public function destroy(Image $image) + public function destroy(Image $image): void { - $this->destroyImagesFromPath($image->path, $image->type); + $this->destroyFileAtPath($image->type, $image->path); $image->delete(); } /** - * Destroys an image at the given path. - * Searches for image thumbnails in addition to main provided path. + * Destroy the underlying image file at the given path. */ - protected function destroyImagesFromPath(string $path, string $imageType): bool + public function destroyFileAtPath(string $type, string $path): void { - $path = $this->storage->adjustPathForDisk($path, $imageType); - $disk = $this->storage->getDisk($imageType); - - $imageFolder = dirname($path); - $imageFileName = basename($path); - $allImages = collect($disk->allFiles($imageFolder)); - - // Delete image files - $imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) { - return basename($imagePath) === $imageFileName; - }); - $disk->delete($imagesToDelete->all()); - - // Cleanup of empty folders - $foldersInvolved = array_merge([$imageFolder], $disk->directories($imageFolder)); - foreach ($foldersInvolved as $directory) { - if ($this->isFolderEmpty($disk, $directory)) { - $disk->deleteDirectory($directory); - } - } - - return true; - } - - /** - * Check whether a folder is empty. - */ - protected function isFolderEmpty(StorageDisk $storage, string $path): bool - { - $files = $storage->files($path); - $folders = $storage->directories($path); - - return count($files) === 0 && count($folders) === 0; + $disk = $this->storage->getDisk($type); + $disk->destroyAllMatchingNameFromPath($path); } /** @@ -325,7 +174,7 @@ class ImageService * * Returns the path of the images that would be/have been deleted. */ - public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = true) + public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = true): array { $types = ['gallery', 'drawio']; $deletedPaths = []; @@ -361,8 +210,6 @@ class ImageService * Attempts to convert the URL to a system storage url then * fetch the data from the disk or storage location. * Returns null if the image data cannot be fetched from storage. - * - * @throws FileNotFoundException */ public function imageUrlToBase64(string $url): ?string { @@ -371,8 +218,6 @@ class ImageService return null; } - $storagePath = $this->storage->adjustPathForDisk($storagePath); - // Apply access control when local_secure_restricted images are active if ($this->storage->usingSecureRestrictedImages()) { if (!$this->checkUserHasAccessToRelationOfImageAtPath($storagePath)) { @@ -412,8 +257,7 @@ class ImageService } // Check local_secure is active - return $this->storage->usingSecureImages() - && $disk instanceof FilesystemAdapter + return $disk->usingSecureImages() // Check the image file exists && $disk->exists($imagePath) // Check the file is likely an image file @@ -426,7 +270,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); } @@ -455,15 +299,15 @@ class ImageService } if ($imageType === 'gallery' || $imageType === 'drawio') { - return Page::visible()->where('id', '=', $image->uploaded_to)->exists(); + return $this->queries->pages->visibleForList()->where('id', '=', $image->uploaded_to)->exists(); } if ($imageType === 'cover_book') { - return Book::visible()->where('id', '=', $image->uploaded_to)->exists(); + return $this->queries->books->visibleForList()->where('id', '=', $image->uploaded_to)->exists(); } if ($imageType === 'cover_bookshelf') { - return Bookshelf::visible()->where('id', '=', $image->uploaded_to)->exists(); + return $this->queries->shelves->visibleForList()->where('id', '=', $image->uploaded_to)->exists(); } return false;