]> BookStack Code Mirror - bookstack/blob - app/Uploads/ImageService.php
Images: Added thin wrapper around image filesystem instances
[bookstack] / app / Uploads / ImageService.php
1 <?php
2
3 namespace BookStack\Uploads;
4
5 use BookStack\Entities\Models\Book;
6 use BookStack\Entities\Models\Bookshelf;
7 use BookStack\Entities\Models\Page;
8 use BookStack\Exceptions\ImageUploadException;
9 use ErrorException;
10 use Exception;
11 use Illuminate\Contracts\Cache\Repository as Cache;
12 use Illuminate\Filesystem\FilesystemManager;
13 use Illuminate\Support\Facades\DB;
14 use Illuminate\Support\Facades\Log;
15 use Illuminate\Support\Str;
16 use Intervention\Image\Exception\NotSupportedException;
17 use Intervention\Image\ImageManager;
18 use Symfony\Component\HttpFoundation\File\UploadedFile;
19 use Symfony\Component\HttpFoundation\StreamedResponse;
20
21 class ImageService
22 {
23     protected static array $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
24
25     public function __construct(
26         protected ImageManager $imageTool,
27         protected FilesystemManager $fileSystem,
28         protected Cache $cache,
29         protected ImageStorage $storage,
30     ) {
31     }
32
33     /**
34      * Saves a new image from an upload.
35      *
36      * @throws ImageUploadException
37      */
38     public function saveNewFromUpload(
39         UploadedFile $uploadedFile,
40         string $type,
41         int $uploadedTo = 0,
42         int $resizeWidth = null,
43         int $resizeHeight = null,
44         bool $keepRatio = true
45     ): Image {
46         $imageName = $uploadedFile->getClientOriginalName();
47         $imageData = file_get_contents($uploadedFile->getRealPath());
48
49         if ($resizeWidth !== null || $resizeHeight !== null) {
50             $imageData = $this->resizeImage($imageData, $resizeWidth, $resizeHeight, $keepRatio);
51         }
52
53         return $this->saveNew($imageName, $imageData, $type, $uploadedTo);
54     }
55
56     /**
57      * Save a new image from a uri-encoded base64 string of data.
58      *
59      * @throws ImageUploadException
60      */
61     public function saveNewFromBase64Uri(string $base64Uri, string $name, string $type, int $uploadedTo = 0): Image
62     {
63         $splitData = explode(';base64,', $base64Uri);
64         if (count($splitData) < 2) {
65             throw new ImageUploadException('Invalid base64 image data provided');
66         }
67         $data = base64_decode($splitData[1]);
68
69         return $this->saveNew($name, $data, $type, $uploadedTo);
70     }
71
72     /**
73      * Save a new image into storage.
74      *
75      * @throws ImageUploadException
76      */
77     public function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
78     {
79         $disk = $this->storage->getDisk($type);
80         $secureUploads = setting('app-secure-images');
81         $fileName = $this->storage->cleanImageFileName($imageName);
82
83         $imagePath = '/uploads/images/' . $type . '/' . date('Y-m') . '/';
84
85         while ($disk->exists($imagePath . $fileName)) {
86             $fileName = Str::random(3) . $fileName;
87         }
88
89         $fullPath = $imagePath . $fileName;
90         if ($secureUploads) {
91             $fullPath = $imagePath . Str::random(16) . '-' . $fileName;
92         }
93
94         try {
95             $disk->put($fullPath, $imageData, true);
96         } catch (Exception $e) {
97             Log::error('Error when attempting image upload:' . $e->getMessage());
98
99             throw new ImageUploadException(trans('errors.path_not_writable', ['filePath' => $fullPath]));
100         }
101
102         $imageDetails = [
103             'name'        => $imageName,
104             'path'        => $fullPath,
105             'url'         => $this->storage->getPublicUrl($fullPath),
106             'type'        => $type,
107             'uploaded_to' => $uploadedTo,
108         ];
109
110         if (user()->id !== 0) {
111             $userId = user()->id;
112             $imageDetails['created_by'] = $userId;
113             $imageDetails['updated_by'] = $userId;
114         }
115
116         $image = (new Image())->forceFill($imageDetails);
117         $image->save();
118
119         return $image;
120     }
121
122     /**
123      * Replace an existing image file in the system using the given file.
124      */
125     public function replaceExistingFromUpload(string $path, string $type, UploadedFile $file): void
126     {
127         $imageData = file_get_contents($file->getRealPath());
128         $disk = $this->storage->getDisk($type);
129         $disk->put($path, $imageData);
130     }
131
132     /**
133      * Checks if the image is a gif. Returns true if it is, else false.
134      */
135     protected function isGif(Image $image): bool
136     {
137         return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
138     }
139
140     /**
141      * Check if the given image and image data is apng.
142      */
143     protected function isApngData(Image $image, string &$imageData): bool
144     {
145         $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
146         if (!$isPng) {
147             return false;
148         }
149
150         $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
151
152         return str_contains($initialHeader, 'acTL');
153     }
154
155     /**
156      * Get the thumbnail for an image.
157      * If $keepRatio is true only the width will be used.
158      * Checks the cache then storage to avoid creating / accessing the filesystem on every check.
159      *
160      * @throws Exception
161      */
162     public function getThumbnail(
163         Image $image,
164         ?int $width,
165         ?int $height,
166         bool $keepRatio = false,
167         bool $shouldCreate = false,
168         bool $canCreate = false,
169     ): ?string {
170         // Do not resize GIF images where we're not cropping
171         if ($keepRatio && $this->isGif($image)) {
172             return $this->storage->getPublicUrl($image->path);
173         }
174
175         $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
176         $imagePath = $image->path;
177         $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
178
179         $thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
180
181         // Return path if in cache
182         $cachedThumbPath = $this->cache->get($thumbCacheKey);
183         if ($cachedThumbPath && !$shouldCreate) {
184             return $this->storage->getPublicUrl($cachedThumbPath);
185         }
186
187         // If thumbnail has already been generated, serve that and cache path
188         $disk = $this->storage->getDisk($image->type);
189         if (!$shouldCreate && $disk->exists($thumbFilePath)) {
190             $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
191
192             return $this->storage->getPublicUrl($thumbFilePath);
193         }
194
195         $imageData = $disk->get($imagePath);
196
197         // Do not resize apng images where we're not cropping
198         if ($keepRatio && $this->isApngData($image, $imageData)) {
199             $this->cache->put($thumbCacheKey, $image->path, 60 * 60 * 72);
200
201             return $this->storage->getPublicUrl($image->path);
202         }
203
204         if (!$shouldCreate && !$canCreate) {
205             return null;
206         }
207
208         // If not in cache and thumbnail does not exist, generate thumb and cache path
209         $thumbData = $this->resizeImage($imageData, $width, $height, $keepRatio);
210         $disk->put($thumbFilePath, $thumbData, true);
211         $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
212
213         return $this->storage->getPublicUrl($thumbFilePath);
214     }
215
216     /**
217      * Resize the image of given data to the specified size, and return the new image data.
218      *
219      * @throws ImageUploadException
220      */
221     protected function resizeImage(string $imageData, ?int $width, ?int $height, bool $keepRatio): string
222     {
223         try {
224             $thumb = $this->imageTool->make($imageData);
225         } catch (ErrorException | NotSupportedException $e) {
226             throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
227         }
228
229         $this->orientImageToOriginalExif($thumb, $imageData);
230
231         if ($keepRatio) {
232             $thumb->resize($width, $height, function ($constraint) {
233                 $constraint->aspectRatio();
234                 $constraint->upsize();
235             });
236         } else {
237             $thumb->fit($width, $height);
238         }
239
240         $thumbData = (string) $thumb->encode();
241
242         // Use original image data if we're keeping the ratio
243         // and the resizing does not save any space.
244         if ($keepRatio && strlen($thumbData) > strlen($imageData)) {
245             return $imageData;
246         }
247
248         return $thumbData;
249     }
250
251     /**
252      * Get the raw data content from an image.
253      *
254      * @throws Exception
255      */
256     public function getImageData(Image $image): string
257     {
258         $disk = $this->storage->getDisk();
259
260         return $disk->get($image->path);
261     }
262
263     /**
264      * Destroy an image along with its revisions, thumbnails and remaining folders.
265      *
266      * @throws Exception
267      */
268     public function destroy(Image $image): void
269     {
270         $disk = $this->storage->getDisk($image->type);
271         $disk->destroyAllMatchingNameFromPath($image->path);
272         $image->delete();
273     }
274
275     /**
276      * Delete gallery and drawings that are not within HTML content of pages or page revisions.
277      * Checks based off of only the image name.
278      * Could be much improved to be more specific but kept it generic for now to be safe.
279      *
280      * Returns the path of the images that would be/have been deleted.
281      */
282     public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = true): array
283     {
284         $types = ['gallery', 'drawio'];
285         $deletedPaths = [];
286
287         Image::query()->whereIn('type', $types)
288             ->chunk(1000, function ($images) use ($checkRevisions, &$deletedPaths, $dryRun) {
289                 /** @var Image $image */
290                 foreach ($images as $image) {
291                     $searchQuery = '%' . basename($image->path) . '%';
292                     $inPage = DB::table('pages')
293                             ->where('html', 'like', $searchQuery)->count() > 0;
294
295                     $inRevision = false;
296                     if ($checkRevisions) {
297                         $inRevision = DB::table('page_revisions')
298                                 ->where('html', 'like', $searchQuery)->count() > 0;
299                     }
300
301                     if (!$inPage && !$inRevision) {
302                         $deletedPaths[] = $image->path;
303                         if (!$dryRun) {
304                             $this->destroy($image);
305                         }
306                     }
307                 }
308             });
309
310         return $deletedPaths;
311     }
312
313     /**
314      * Convert an image URI to a Base64 encoded string.
315      * Attempts to convert the URL to a system storage url then
316      * fetch the data from the disk or storage location.
317      * Returns null if the image data cannot be fetched from storage.
318      */
319     public function imageUrlToBase64(string $url): ?string
320     {
321         $storagePath = $this->storage->urlToPath($url);
322         if (empty($url) || is_null($storagePath)) {
323             return null;
324         }
325
326         // Apply access control when local_secure_restricted images are active
327         if ($this->storage->usingSecureRestrictedImages()) {
328             if (!$this->checkUserHasAccessToRelationOfImageAtPath($storagePath)) {
329                 return null;
330             }
331         }
332
333         $disk = $this->storage->getDisk();
334         $imageData = null;
335         if ($disk->exists($storagePath)) {
336             $imageData = $disk->get($storagePath);
337         }
338
339         if (is_null($imageData)) {
340             return null;
341         }
342
343         $extension = pathinfo($url, PATHINFO_EXTENSION);
344         if ($extension === 'svg') {
345             $extension = 'svg+xml';
346         }
347
348         return 'data:image/' . $extension . ';base64,' . base64_encode($imageData);
349     }
350
351     /**
352      * Check if the given path exists and is accessible in the local secure image system.
353      * Returns false if local_secure is not in use, if the file does not exist, if the
354      * file is likely not a valid image, or if permission does not allow access.
355      */
356     public function pathAccessibleInLocalSecure(string $imagePath): bool
357     {
358         $disk = $this->storage->getDisk('gallery');
359
360         if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
361             return false;
362         }
363
364         // Check local_secure is active
365         return $disk->usingSecureImages()
366             // Check the image file exists
367             && $disk->exists($imagePath)
368             // Check the file is likely an image file
369             && str_starts_with($disk->mimeType($imagePath), 'image/');
370     }
371
372     /**
373      * Check that the current user has access to the relation
374      * of the image at the given path.
375      */
376     protected function checkUserHasAccessToRelationOfImageAtPath(string $path): bool
377     {
378         if (str_starts_with($path, '/uploads/images/')) {
379             $path = substr($path, 15);
380         }
381
382         // Strip thumbnail element from path if existing
383         $originalPathSplit = array_filter(explode('/', $path), function (string $part) {
384             $resizedDir = (str_starts_with($part, 'thumbs-') || str_starts_with($part, 'scaled-'));
385             $missingExtension = !str_contains($part, '.');
386
387             return !($resizedDir && $missingExtension);
388         });
389
390         // Build a database-format image path and search for the image entry
391         $fullPath = '/uploads/images/' . ltrim(implode('/', $originalPathSplit), '/');
392         $image = Image::query()->where('path', '=', $fullPath)->first();
393
394         if (is_null($image)) {
395             return false;
396         }
397
398         $imageType = $image->type;
399
400         // Allow user or system (logo) images
401         // (No specific relation control but may still have access controlled by auth)
402         if ($imageType === 'user' || $imageType === 'system') {
403             return true;
404         }
405
406         if ($imageType === 'gallery' || $imageType === 'drawio') {
407             return Page::visible()->where('id', '=', $image->uploaded_to)->exists();
408         }
409
410         if ($imageType === 'cover_book') {
411             return Book::visible()->where('id', '=', $image->uploaded_to)->exists();
412         }
413
414         if ($imageType === 'cover_bookshelf') {
415             return Bookshelf::visible()->where('id', '=', $image->uploaded_to)->exists();
416         }
417
418         return false;
419     }
420
421     /**
422      * For the given path, if existing, provide a response that will stream the image contents.
423      */
424     public function streamImageFromStorageResponse(string $imageType, string $path): StreamedResponse
425     {
426         $disk = $this->storage->getDisk($imageType);
427
428         return $disk->response($path);
429     }
430
431     /**
432      * Check if the given image extension is supported by BookStack.
433      * The extension must not be altered in this function. This check should provide a guarantee
434      * that the provided extension is safe to use for the image to be saved.
435      */
436     public static function isExtensionSupported(string $extension): bool
437     {
438         return in_array($extension, static::$supportedExtensions);
439     }
440 }