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