]> BookStack Code Mirror - bookstack/blob - app/Uploads/ImageService.php
Fixed failing test after drawio default url change
[bookstack] / app / Uploads / ImageService.php
1 <?php
2
3 namespace BookStack\Uploads;
4
5 use BookStack\Exceptions\ImageUploadException;
6 use ErrorException;
7 use Exception;
8 use GuzzleHttp\Psr7\Utils;
9 use Illuminate\Contracts\Cache\Repository as Cache;
10 use Illuminate\Contracts\Filesystem\FileNotFoundException;
11 use Illuminate\Contracts\Filesystem\Filesystem as Storage;
12 use Illuminate\Filesystem\FilesystemAdapter;
13 use Illuminate\Filesystem\FilesystemManager;
14 use Illuminate\Support\Facades\DB;
15 use Illuminate\Support\Facades\Log;
16 use Illuminate\Support\Str;
17 use Intervention\Image\Exception\NotSupportedException;
18 use Intervention\Image\Image as InterventionImage;
19 use Intervention\Image\ImageManager;
20 use League\Flysystem\Util;
21 use Psr\SimpleCache\InvalidArgumentException;
22 use Symfony\Component\HttpFoundation\File\UploadedFile;
23 use Symfony\Component\HttpFoundation\StreamedResponse;
24
25 class ImageService
26 {
27     protected $imageTool;
28     protected $cache;
29     protected $storageUrl;
30     protected $image;
31     protected $fileSystem;
32
33     protected static $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
34
35     /**
36      * ImageService constructor.
37      */
38     public function __construct(Image $image, ImageManager $imageTool, FilesystemManager $fileSystem, Cache $cache)
39     {
40         $this->image = $image;
41         $this->imageTool = $imageTool;
42         $this->fileSystem = $fileSystem;
43         $this->cache = $cache;
44     }
45
46     /**
47      * Get the storage that will be used for storing images.
48      */
49     protected function getStorageDisk(string $imageType = ''): Storage
50     {
51         return $this->fileSystem->disk($this->getStorageDiskName($imageType));
52     }
53
54     /**
55      * Check if local secure image storage (Fetched behind authentication)
56      * is currently active in the instance.
57      */
58     protected function usingSecureImages(): bool
59     {
60         return $this->getStorageDiskName('gallery') === 'local_secure_images';
61     }
62
63     /**
64      * Change the originally provided path to fit any disk-specific requirements.
65      * This also ensures the path is kept to the expected root folders.
66      */
67     protected function adjustPathForStorageDisk(string $path, string $imageType = ''): string
68     {
69         $path = Util::normalizePath(str_replace('uploads/images/', '', $path));
70
71         if ($this->getStorageDiskName($imageType) === 'local_secure_images') {
72             return $path;
73         }
74
75         return 'uploads/images/' . $path;
76     }
77
78     /**
79      * Get the name of the storage disk to use.
80      */
81     protected function getStorageDiskName(string $imageType): string
82     {
83         $storageType = config('filesystems.images');
84
85         // Ensure system images (App logo) are uploaded to a public space
86         if ($imageType === 'system' && $storageType === 'local_secure') {
87             $storageType = 'local';
88         }
89
90         if ($storageType === 'local_secure') {
91             $storageType = 'local_secure_images';
92         }
93
94         return $storageType;
95     }
96
97     /**
98      * Saves a new image from an upload.
99      *
100      * @throws ImageUploadException
101      *
102      * @return mixed
103      */
104     public function saveNewFromUpload(
105         UploadedFile $uploadedFile,
106         string $type,
107         int $uploadedTo = 0,
108         int $resizeWidth = null,
109         int $resizeHeight = null,
110         bool $keepRatio = true
111     ) {
112         $imageName = $uploadedFile->getClientOriginalName();
113         $imageData = file_get_contents($uploadedFile->getRealPath());
114
115         if ($resizeWidth !== null || $resizeHeight !== null) {
116             $imageData = $this->resizeImage($imageData, $resizeWidth, $resizeHeight, $keepRatio);
117         }
118
119         return $this->saveNew($imageName, $imageData, $type, $uploadedTo);
120     }
121
122     /**
123      * Save a new image from a uri-encoded base64 string of data.
124      *
125      * @throws ImageUploadException
126      */
127     public function saveNewFromBase64Uri(string $base64Uri, string $name, string $type, int $uploadedTo = 0): Image
128     {
129         $splitData = explode(';base64,', $base64Uri);
130         if (count($splitData) < 2) {
131             throw new ImageUploadException('Invalid base64 image data provided');
132         }
133         $data = base64_decode($splitData[1]);
134
135         return $this->saveNew($name, $data, $type, $uploadedTo);
136     }
137
138     /**
139      * Save a new image into storage.
140      *
141      * @throws ImageUploadException
142      */
143     public function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
144     {
145         $storage = $this->getStorageDisk($type);
146         $secureUploads = setting('app-secure-images');
147         $fileName = $this->cleanImageFileName($imageName);
148
149         $imagePath = '/uploads/images/' . $type . '/' . date('Y-m') . '/';
150
151         while ($storage->exists($this->adjustPathForStorageDisk($imagePath . $fileName, $type))) {
152             $fileName = Str::random(3) . $fileName;
153         }
154
155         $fullPath = $imagePath . $fileName;
156         if ($secureUploads) {
157             $fullPath = $imagePath . Str::random(16) . '-' . $fileName;
158         }
159
160         try {
161             $this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($fullPath, $type), $imageData);
162         } catch (Exception $e) {
163             Log::error('Error when attempting image upload:' . $e->getMessage());
164
165             throw new ImageUploadException(trans('errors.path_not_writable', ['filePath' => $fullPath]));
166         }
167
168         $imageDetails = [
169             'name'        => $imageName,
170             'path'        => $fullPath,
171             'url'         => $this->getPublicUrl($fullPath),
172             'type'        => $type,
173             'uploaded_to' => $uploadedTo,
174         ];
175
176         if (user()->id !== 0) {
177             $userId = user()->id;
178             $imageDetails['created_by'] = $userId;
179             $imageDetails['updated_by'] = $userId;
180         }
181
182         $image = $this->image->newInstance();
183         $image->forceFill($imageDetails)->save();
184
185         return $image;
186     }
187
188     /**
189      * Save image data for the given path in the public space, if possible,
190      * for the provided storage mechanism.
191      */
192     protected function saveImageDataInPublicSpace(Storage $storage, string $path, string $data)
193     {
194         $storage->put($path, $data);
195
196         // Set visibility when a non-AWS-s3, s3-like storage option is in use.
197         // Done since this call can break s3-like services but desired for other image stores.
198         // Attempting to set ACL during above put request requires different permissions
199         // hence would technically be a breaking change for actual s3 usage.
200         $usingS3 = strtolower(config('filesystems.images')) === 's3';
201         $usingS3Like = $usingS3 && !is_null(config('filesystems.disks.s3.endpoint'));
202         if (!$usingS3Like) {
203             $storage->setVisibility($path, 'public');
204         }
205     }
206
207     /**
208      * Clean up an image file name to be both URL and storage safe.
209      */
210     protected function cleanImageFileName(string $name): string
211     {
212         $name = str_replace(' ', '-', $name);
213         $nameParts = explode('.', $name);
214         $extension = array_pop($nameParts);
215         $name = implode('-', $nameParts);
216         $name = Str::slug($name);
217
218         if (strlen($name) === 0) {
219             $name = Str::random(10);
220         }
221
222         return $name . '.' . $extension;
223     }
224
225     /**
226      * Checks if the image is a gif. Returns true if it is, else false.
227      */
228     protected function isGif(Image $image): bool
229     {
230         return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
231     }
232
233     /**
234      * Check if the given image and image data is apng.
235      */
236     protected function isApngData(Image $image, string &$imageData): bool
237     {
238         $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
239         if (!$isPng) {
240             return false;
241         }
242
243         $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
244
245         return strpos($initialHeader, 'acTL') !== false;
246     }
247
248     /**
249      * Get the thumbnail for an image.
250      * If $keepRatio is true only the width will be used.
251      * Checks the cache then storage to avoid creating / accessing the filesystem on every check.
252      *
253      * @throws Exception
254      * @throws InvalidArgumentException
255      */
256     public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false): string
257     {
258         // Do not resize GIF images where we're not cropping
259         if ($keepRatio && $this->isGif($image)) {
260             return $this->getPublicUrl($image->path);
261         }
262
263         $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
264         $imagePath = $image->path;
265         $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
266
267         $thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
268
269         // Return path if in cache
270         $cachedThumbPath = $this->cache->get($thumbCacheKey);
271         if ($cachedThumbPath) {
272             return $this->getPublicUrl($cachedThumbPath);
273         }
274
275         // If thumbnail has already been generated, serve that and cache path
276         $storage = $this->getStorageDisk($image->type);
277         if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
278             $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
279
280             return $this->getPublicUrl($thumbFilePath);
281         }
282
283         $imageData = $storage->get($this->adjustPathForStorageDisk($imagePath, $image->type));
284
285         // Do not resize apng images where we're not cropping
286         if ($keepRatio && $this->isApngData($image, $imageData)) {
287             $this->cache->put($thumbCacheKey, $image->path, 60 * 60 * 72);
288
289             return $this->getPublicUrl($image->path);
290         }
291
292         // If not in cache and thumbnail does not exist, generate thumb and cache path
293         $thumbData = $this->resizeImage($imageData, $width, $height, $keepRatio);
294         $this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($thumbFilePath, $image->type), $thumbData);
295         $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
296
297         return $this->getPublicUrl($thumbFilePath);
298     }
299
300     /**
301      * Resize the image of given data to the specified size, and return the new image data.
302      *
303      * @throws ImageUploadException
304      */
305     protected function resizeImage(string $imageData, ?int $width, ?int $height, bool $keepRatio): string
306     {
307         try {
308             $thumb = $this->imageTool->make($imageData);
309         } catch (ErrorException|NotSupportedException $e) {
310             throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
311         }
312
313         $this->orientImageToOriginalExif($thumb, $imageData);
314
315         if ($keepRatio) {
316             $thumb->resize($width, $height, function ($constraint) {
317                 $constraint->aspectRatio();
318                 $constraint->upsize();
319             });
320         } else {
321             $thumb->fit($width, $height);
322         }
323
324         $thumbData = (string) $thumb->encode();
325
326         // Use original image data if we're keeping the ratio
327         // and the resizing does not save any space.
328         if ($keepRatio && strlen($thumbData) > strlen($imageData)) {
329             return $imageData;
330         }
331
332         return $thumbData;
333     }
334
335     /**
336      * Orientate the given intervention image based upon the given original image data.
337      * Intervention does have an `orientate` method but the exif data it needs is lost before it
338      * can be used (At least when created using binary string data) so we need to do some
339      * implementation on our side to use the original image data.
340      * Bulk of logic taken from: https://github.com/Intervention/image/blob/b734a4988b2148e7d10364b0609978a88d277536/src/Intervention/Image/Commands/OrientateCommand.php
341      * Copyright (c) Oliver Vogel, MIT License.
342      */
343     protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
344     {
345         if (!extension_loaded('exif')) {
346             return;
347         }
348
349         $stream = Utils::streamFor($originalData)->detach();
350         $exif = @exif_read_data($stream);
351         $orientation = $exif ? ($exif['Orientation'] ?? null) : null;
352
353         switch ($orientation) {
354             case 2:
355                 $image->flip();
356                 break;
357             case 3:
358                 $image->rotate(180);
359                 break;
360             case 4:
361                 $image->rotate(180)->flip();
362                 break;
363             case 5:
364                 $image->rotate(270)->flip();
365                 break;
366             case 6:
367                 $image->rotate(270);
368                 break;
369             case 7:
370                 $image->rotate(90)->flip();
371                 break;
372             case 8:
373                 $image->rotate(90);
374                 break;
375         }
376     }
377
378     /**
379      * Get the raw data content from an image.
380      *
381      * @throws FileNotFoundException
382      */
383     public function getImageData(Image $image): string
384     {
385         $storage = $this->getStorageDisk();
386
387         return $storage->get($this->adjustPathForStorageDisk($image->path, $image->type));
388     }
389
390     /**
391      * Destroy an image along with its revisions, thumbnails and remaining folders.
392      *
393      * @throws Exception
394      */
395     public function destroy(Image $image)
396     {
397         $this->destroyImagesFromPath($image->path, $image->type);
398         $image->delete();
399     }
400
401     /**
402      * Destroys an image at the given path.
403      * Searches for image thumbnails in addition to main provided path.
404      */
405     protected function destroyImagesFromPath(string $path, string $imageType): bool
406     {
407         $path = $this->adjustPathForStorageDisk($path, $imageType);
408         $storage = $this->getStorageDisk($imageType);
409
410         $imageFolder = dirname($path);
411         $imageFileName = basename($path);
412         $allImages = collect($storage->allFiles($imageFolder));
413
414         // Delete image files
415         $imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
416             return basename($imagePath) === $imageFileName;
417         });
418         $storage->delete($imagesToDelete->all());
419
420         // Cleanup of empty folders
421         $foldersInvolved = array_merge([$imageFolder], $storage->directories($imageFolder));
422         foreach ($foldersInvolved as $directory) {
423             if ($this->isFolderEmpty($storage, $directory)) {
424                 $storage->deleteDirectory($directory);
425             }
426         }
427
428         return true;
429     }
430
431     /**
432      * Check whether a folder is empty.
433      */
434     protected function isFolderEmpty(Storage $storage, string $path): bool
435     {
436         $files = $storage->files($path);
437         $folders = $storage->directories($path);
438
439         return count($files) === 0 && count($folders) === 0;
440     }
441
442     /**
443      * Delete gallery and drawings that are not within HTML content of pages or page revisions.
444      * Checks based off of only the image name.
445      * Could be much improved to be more specific but kept it generic for now to be safe.
446      *
447      * Returns the path of the images that would be/have been deleted.
448      */
449     public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = true)
450     {
451         $types = ['gallery', 'drawio'];
452         $deletedPaths = [];
453
454         $this->image->newQuery()->whereIn('type', $types)
455             ->chunk(1000, function ($images) use ($checkRevisions, &$deletedPaths, $dryRun) {
456                 foreach ($images as $image) {
457                     $searchQuery = '%' . basename($image->path) . '%';
458                     $inPage = DB::table('pages')
459                             ->where('html', 'like', $searchQuery)->count() > 0;
460
461                     $inRevision = false;
462                     if ($checkRevisions) {
463                         $inRevision = DB::table('page_revisions')
464                                 ->where('html', 'like', $searchQuery)->count() > 0;
465                     }
466
467                     if (!$inPage && !$inRevision) {
468                         $deletedPaths[] = $image->path;
469                         if (!$dryRun) {
470                             $this->destroy($image);
471                         }
472                     }
473                 }
474             });
475
476         return $deletedPaths;
477     }
478
479     /**
480      * Convert an image URI to a Base64 encoded string.
481      * Attempts to convert the URL to a system storage url then
482      * fetch the data from the disk or storage location.
483      * Returns null if the image data cannot be fetched from storage.
484      *
485      * @throws FileNotFoundException
486      */
487     public function imageUriToBase64(string $uri): ?string
488     {
489         $storagePath = $this->imageUrlToStoragePath($uri);
490         if (empty($uri) || is_null($storagePath)) {
491             return null;
492         }
493
494         $storagePath = $this->adjustPathForStorageDisk($storagePath);
495         $storage = $this->getStorageDisk();
496         $imageData = null;
497         if ($storage->exists($storagePath)) {
498             $imageData = $storage->get($storagePath);
499         }
500
501         if (is_null($imageData)) {
502             return null;
503         }
504
505         $extension = pathinfo($uri, PATHINFO_EXTENSION);
506         if ($extension === 'svg') {
507             $extension = 'svg+xml';
508         }
509
510         return 'data:image/' . $extension . ';base64,' . base64_encode($imageData);
511     }
512
513     /**
514      * Check if the given path exists in the local secure image system.
515      * Returns false if local_secure is not in use.
516      */
517     public function pathExistsInLocalSecure(string $imagePath): bool
518     {
519         /** @var FilesystemAdapter $disk */
520         $disk = $this->getStorageDisk('gallery');
521
522         // Check local_secure is active
523         return $this->usingSecureImages()
524             && $disk instanceof FilesystemAdapter
525             // Check the image file exists
526             && $disk->exists($imagePath)
527             // Check the file is likely an image file
528             && strpos($disk->getMimetype($imagePath), 'image/') === 0;
529     }
530
531     /**
532      * For the given path, if existing, provide a response that will stream the image contents.
533      */
534     public function streamImageFromStorageResponse(string $imageType, string $path): StreamedResponse
535     {
536         $disk = $this->getStorageDisk($imageType);
537
538         return $disk->response($path);
539     }
540
541     /**
542      * Check if the given image extension is supported by BookStack.
543      * The extension must not be altered in this function. This check should provide a guarantee
544      * that the provided extension is safe to use for the image to be saved.
545      */
546     public static function isExtensionSupported(string $extension): bool
547     {
548         return in_array($extension, static::$supportedExtensions);
549     }
550
551     /**
552      * Get a storage path for the given image URL.
553      * Ensures the path will start with "uploads/images".
554      * Returns null if the url cannot be resolved to a local URL.
555      */
556     private function imageUrlToStoragePath(string $url): ?string
557     {
558         $url = ltrim(trim($url), '/');
559
560         // Handle potential relative paths
561         $isRelative = strpos($url, 'http') !== 0;
562         if ($isRelative) {
563             if (strpos(strtolower($url), 'uploads/images') === 0) {
564                 return trim($url, '/');
565             }
566
567             return null;
568         }
569
570         // Handle local images based on paths on the same domain
571         $potentialHostPaths = [
572             url('uploads/images/'),
573             $this->getPublicUrl('/uploads/images/'),
574         ];
575
576         foreach ($potentialHostPaths as $potentialBasePath) {
577             $potentialBasePath = strtolower($potentialBasePath);
578             if (strpos(strtolower($url), $potentialBasePath) === 0) {
579                 return 'uploads/images/' . trim(substr($url, strlen($potentialBasePath)), '/');
580             }
581         }
582
583         return null;
584     }
585
586     /**
587      * Gets a public facing url for an image by checking relevant environment variables.
588      * If s3-style store is in use it will default to guessing a public bucket URL.
589      */
590     private function getPublicUrl(string $filePath): string
591     {
592         if (is_null($this->storageUrl)) {
593             $storageUrl = config('filesystems.url');
594
595             // Get the standard public s3 url if s3 is set as storage type
596             // Uses the nice, short URL if bucket name has no periods in otherwise the longer
597             // region-based url will be used to prevent http issues.
598             if ($storageUrl == false && config('filesystems.images') === 's3') {
599                 $storageDetails = config('filesystems.disks.s3');
600                 if (strpos($storageDetails['bucket'], '.') === false) {
601                     $storageUrl = 'https://' . $storageDetails['bucket'] . '.s3.amazonaws.com';
602                 } else {
603                     $storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
604                 }
605             }
606
607             $this->storageUrl = $storageUrl;
608         }
609
610         $basePath = ($this->storageUrl == false) ? url('/') : $this->storageUrl;
611
612         return rtrim($basePath, '/') . $filePath;
613     }
614 }