]> BookStack Code Mirror - bookstack/blob - app/Uploads/ImageStorageDisk.php
Images: Changed how new image permissions are set
[bookstack] / app / Uploads / ImageStorageDisk.php
1 <?php
2
3 namespace BookStack\Uploads;
4
5 use BookStack\Util\FilePathNormalizer;
6 use Illuminate\Contracts\Filesystem\Filesystem;
7 use Illuminate\Filesystem\FilesystemAdapter;
8 use Illuminate\Support\Facades\Log;
9 use League\Flysystem\UnableToSetVisibility;
10 use Symfony\Component\HttpFoundation\StreamedResponse;
11
12 class ImageStorageDisk
13 {
14     public function __construct(
15         protected string $diskName,
16         protected Filesystem $filesystem,
17     ) {
18     }
19
20     /**
21      * Check if local secure image storage (Fetched behind authentication)
22      * is currently active in the instance.
23      */
24     public function usingSecureImages(): bool
25     {
26         return $this->diskName === 'local_secure_images';
27     }
28
29     /**
30      * Change the originally provided path to fit any disk-specific requirements.
31      * This also ensures the path is kept to the expected root folders.
32      */
33     protected function adjustPathForDisk(string $path): string
34     {
35         $trimmed = str_replace('uploads/images/', '', $path);
36         $normalized = FilePathNormalizer::normalize($trimmed);
37
38         if ($this->usingSecureImages()) {
39             return $normalized;
40         }
41
42         return 'uploads/images/' . $normalized;
43     }
44
45     /**
46      * Check if a file at the given path exists.
47      */
48     public function exists(string $path): bool
49     {
50         return $this->filesystem->exists($this->adjustPathForDisk($path));
51     }
52
53     /**
54      * Get the file at the given path.
55      */
56     public function get(string $path): ?string
57     {
58         return $this->filesystem->get($this->adjustPathForDisk($path));
59     }
60
61     /**
62      * Get a stream to the file at the given path.
63      * @returns ?resource
64      */
65     public function stream(string $path): mixed
66     {
67         return $this->filesystem->readStream($this->adjustPathForDisk($path));
68     }
69
70     /**
71      * Save the given image data at the given path. Can choose to set
72      * the image as public which will update its visibility after saving.
73      */
74     public function put(string $path, string $data, bool $makePublic = false): void
75     {
76         $path = $this->adjustPathForDisk($path);
77         $this->filesystem->put($path, $data);
78
79         // Set public visibility to ensure public access on S3, or that the file is accessible
80         // to other processes (like web-servers) for local file storage options.
81         // We avoid attempting this for (non-AWS) s3-like systems (even in a try-catch) as
82         // we've always avoided setting permissions for s3-like due to potential issues,
83         // with docs advising setting pre-configured permissions instead.
84         // We also don't do this as the default filesystem/driver level as that can technically
85         // require different ACLs for S3, and this provides us more logical control.
86         if ($makePublic && !$this->isS3Like()) {
87             try {
88                 $this->filesystem->setVisibility($path, 'public');
89             } catch (UnableToSetVisibility $e) {
90                 Log::warning("Unable to set visibility for image upload with relative path: {$path}");
91             }
92         }
93     }
94
95     /**
96      * Destroys an image at the given path.
97      * Searches for image thumbnails in addition to main provided path.
98      */
99     public function destroyAllMatchingNameFromPath(string $path): void
100     {
101         $path = $this->adjustPathForDisk($path);
102
103         $imageFolder = dirname($path);
104         $imageFileName = basename($path);
105         $allImages = collect($this->filesystem->allFiles($imageFolder));
106
107         // Delete image files
108         $imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
109             return basename($imagePath) === $imageFileName;
110         });
111         $this->filesystem->delete($imagesToDelete->all());
112
113         // Cleanup of empty folders
114         $foldersInvolved = array_merge([$imageFolder], $this->filesystem->directories($imageFolder));
115         foreach ($foldersInvolved as $directory) {
116             if ($this->isFolderEmpty($directory)) {
117                 $this->filesystem->deleteDirectory($directory);
118             }
119         }
120     }
121
122     /**
123      * Get the mime type of the file at the given path.
124      * Only works for local filesystem adapters.
125      */
126     public function mimeType(string $path): string
127     {
128         $path = $this->adjustPathForDisk($path);
129         return $this->filesystem instanceof FilesystemAdapter ? $this->filesystem->mimeType($path) : '';
130     }
131
132     /**
133      * Get a stream response for the image at the given path.
134      */
135     public function response(string $path): StreamedResponse
136     {
137         return $this->filesystem->response($this->adjustPathForDisk($path));
138     }
139
140     /**
141      * Check if the image storage in use is an S3-like (but not likely S3) external system.
142      */
143     protected function isS3Like(): bool
144     {
145         $usingS3 = $this->diskName === 's3';
146         return $usingS3 && !is_null(config('filesystems.disks.s3.endpoint'));
147     }
148
149     /**
150      * Check whether a folder is empty.
151      */
152     protected function isFolderEmpty(string $path): bool
153     {
154         $files = $this->filesystem->files($path);
155         $folders = $this->filesystem->directories($path);
156
157         return count($files) === 0 && count($folders) === 0;
158     }
159 }