]> BookStack Code Mirror - bookstack/blob - app/Uploads/AttachmentService.php
Fixed notification preferences URL in email
[bookstack] / app / Uploads / AttachmentService.php
1 <?php
2
3 namespace BookStack\Uploads;
4
5 use BookStack\Exceptions\FileUploadException;
6 use Exception;
7 use Illuminate\Contracts\Filesystem\Filesystem as Storage;
8 use Illuminate\Filesystem\FilesystemManager;
9 use Illuminate\Support\Facades\Log;
10 use Illuminate\Support\Str;
11 use League\Flysystem\WhitespacePathNormalizer;
12 use Symfony\Component\HttpFoundation\File\UploadedFile;
13
14 class AttachmentService
15 {
16     protected FilesystemManager $fileSystem;
17
18     /**
19      * AttachmentService constructor.
20      */
21     public function __construct(FilesystemManager $fileSystem)
22     {
23         $this->fileSystem = $fileSystem;
24     }
25
26     /**
27      * Get the storage that will be used for storing files.
28      */
29     protected function getStorageDisk(): Storage
30     {
31         return $this->fileSystem->disk($this->getStorageDiskName());
32     }
33
34     /**
35      * Get the name of the storage disk to use.
36      */
37     protected function getStorageDiskName(): string
38     {
39         $storageType = config('filesystems.attachments');
40
41         // Change to our secure-attachment disk if any of the local options
42         // are used to prevent escaping that location.
43         if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') {
44             $storageType = 'local_secure_attachments';
45         }
46
47         return $storageType;
48     }
49
50     /**
51      * Change the originally provided path to fit any disk-specific requirements.
52      * This also ensures the path is kept to the expected root folders.
53      */
54     protected function adjustPathForStorageDisk(string $path): string
55     {
56         $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path));
57
58         if ($this->getStorageDiskName() === 'local_secure_attachments') {
59             return $path;
60         }
61
62         return 'uploads/files/' . $path;
63     }
64
65     /**
66      * Stream an attachment from storage.
67      *
68      * @return resource|null
69      */
70     public function streamAttachmentFromStorage(Attachment $attachment)
71     {
72         return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($attachment->path));
73     }
74
75     /**
76      * Read the file size of an attachment from storage, in bytes.
77      */
78     public function getAttachmentFileSize(Attachment $attachment): int
79     {
80         return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($attachment->path));
81     }
82
83     /**
84      * Store a new attachment upon user upload.
85      *
86      * @throws FileUploadException
87      */
88     public function saveNewUpload(UploadedFile $uploadedFile, int $pageId): Attachment
89     {
90         $attachmentName = $uploadedFile->getClientOriginalName();
91         $attachmentPath = $this->putFileInStorage($uploadedFile);
92         $largestExistingOrder = Attachment::query()->where('uploaded_to', '=', $pageId)->max('order');
93
94         /** @var Attachment $attachment */
95         $attachment = Attachment::query()->forceCreate([
96             'name'        => $attachmentName,
97             'path'        => $attachmentPath,
98             'extension'   => $uploadedFile->getClientOriginalExtension(),
99             'uploaded_to' => $pageId,
100             'created_by'  => user()->id,
101             'updated_by'  => user()->id,
102             'order'       => $largestExistingOrder + 1,
103         ]);
104
105         return $attachment;
106     }
107
108     /**
109      * Store an upload, saving to a file and deleting any existing uploads
110      * attached to that file.
111      *
112      * @throws FileUploadException
113      */
114     public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attachment): Attachment
115     {
116         if (!$attachment->external) {
117             $this->deleteFileInStorage($attachment);
118         }
119
120         $attachmentName = $uploadedFile->getClientOriginalName();
121         $attachmentPath = $this->putFileInStorage($uploadedFile);
122
123         $attachment->name = $attachmentName;
124         $attachment->path = $attachmentPath;
125         $attachment->external = false;
126         $attachment->extension = $uploadedFile->getClientOriginalExtension();
127         $attachment->save();
128
129         return $attachment;
130     }
131
132     /**
133      * Save a new File attachment from a given link and name.
134      */
135     public function saveNewFromLink(string $name, string $link, int $page_id): Attachment
136     {
137         $largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
138
139         return Attachment::forceCreate([
140             'name'        => $name,
141             'path'        => $link,
142             'external'    => true,
143             'extension'   => '',
144             'uploaded_to' => $page_id,
145             'created_by'  => user()->id,
146             'updated_by'  => user()->id,
147             'order'       => $largestExistingOrder + 1,
148         ]);
149     }
150
151     /**
152      * Updates the ordering for a listing of attached files.
153      */
154     public function updateFileOrderWithinPage(array $attachmentOrder, string $pageId)
155     {
156         foreach ($attachmentOrder as $index => $attachmentId) {
157             Attachment::query()->where('uploaded_to', '=', $pageId)
158                 ->where('id', '=', $attachmentId)
159                 ->update(['order' => $index]);
160         }
161     }
162
163     /**
164      * Update the details of a file.
165      */
166     public function updateFile(Attachment $attachment, array $requestData): Attachment
167     {
168         $attachment->name = $requestData['name'];
169         $link = trim($requestData['link'] ?? '');
170
171         if (!empty($link)) {
172             if (!$attachment->external) {
173                 $this->deleteFileInStorage($attachment);
174                 $attachment->external = true;
175                 $attachment->extension = '';
176             }
177             $attachment->path = $requestData['link'];
178         }
179
180         $attachment->save();
181
182         return $attachment->refresh();
183     }
184
185     /**
186      * Delete a File from the database and storage.
187      *
188      * @throws Exception
189      */
190     public function deleteFile(Attachment $attachment)
191     {
192         if (!$attachment->external) {
193             $this->deleteFileInStorage($attachment);
194         }
195
196         $attachment->delete();
197     }
198
199     /**
200      * Delete a file from the filesystem it sits on.
201      * Cleans any empty leftover folders.
202      */
203     protected function deleteFileInStorage(Attachment $attachment)
204     {
205         $storage = $this->getStorageDisk();
206         $dirPath = $this->adjustPathForStorageDisk(dirname($attachment->path));
207
208         $storage->delete($this->adjustPathForStorageDisk($attachment->path));
209         if (count($storage->allFiles($dirPath)) === 0) {
210             $storage->deleteDirectory($dirPath);
211         }
212     }
213
214     /**
215      * Store a file in storage with the given filename.
216      *
217      * @throws FileUploadException
218      */
219     protected function putFileInStorage(UploadedFile $uploadedFile): string
220     {
221         $storage = $this->getStorageDisk();
222         $basePath = 'uploads/files/' . date('Y-m-M') . '/';
223
224         $uploadFileName = Str::random(16) . '-' . $uploadedFile->getClientOriginalExtension();
225         while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) {
226             $uploadFileName = Str::random(3) . $uploadFileName;
227         }
228
229         $attachmentStream = fopen($uploadedFile->getRealPath(), 'r');
230         $attachmentPath = $basePath . $uploadFileName;
231
232         try {
233             $storage->writeStream($this->adjustPathForStorageDisk($attachmentPath), $attachmentStream);
234         } catch (Exception $e) {
235             Log::error('Error when attempting file upload:' . $e->getMessage());
236
237             throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $attachmentPath]));
238         }
239
240         return $attachmentPath;
241     }
242
243     /**
244      * Get the file validation rules for attachments.
245      */
246     public function getFileValidationRules(): array
247     {
248         return ['file', 'max:' . (config('app.upload_limit') * 1000)];
249     }
250 }