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