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