AttachmentSubmitHandler::upload()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 16
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 8
nc 1
nop 3
dl 0
loc 16
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
4
 *
5
 * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
6
 *
7
 * This program is free software: you can redistribute it and/or modify
8
 * it under the terms of the GNU Affero General Public License as published
9
 * by the Free Software Foundation, either version 3 of the License, or
10
 * (at your option) any later version.
11
 *
12
 * This program is distributed in the hope that it will be useful,
13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15
 * GNU Affero General Public License for more details.
16
 *
17
 * You should have received a copy of the GNU Affero General Public License
18
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
19
 */
20
21
declare(strict_types=1);
22
23
namespace App\Services\Attachments;
24
25
use App\Entity\Attachments\Attachment;
26
use App\Entity\Attachments\AttachmentContainingDBElement;
27
use App\Entity\Attachments\AttachmentType;
28
use App\Entity\Attachments\AttachmentTypeAttachment;
29
use App\Entity\Attachments\CategoryAttachment;
30
use App\Entity\Attachments\CurrencyAttachment;
31
use App\Entity\Attachments\LabelAttachment;
32
use App\Entity\Attachments\ProjectAttachment;
33
use App\Entity\Attachments\FootprintAttachment;
34
use App\Entity\Attachments\GroupAttachment;
35
use App\Entity\Attachments\ManufacturerAttachment;
36
use App\Entity\Attachments\MeasurementUnitAttachment;
37
use App\Entity\Attachments\PartAttachment;
38
use App\Entity\Attachments\StorelocationAttachment;
39
use App\Entity\Attachments\SupplierAttachment;
40
use App\Entity\Attachments\UserAttachment;
41
use App\Exceptions\AttachmentDownloadException;
42
use const DIRECTORY_SEPARATOR;
43
use function get_class;
44
use InvalidArgumentException;
45
use RuntimeException;
46
use Symfony\Component\Filesystem\Filesystem;
47
use Symfony\Component\HttpFoundation\File\UploadedFile;
48
use Symfony\Component\Mime\MimeTypesInterface;
49
use Symfony\Component\OptionsResolver\OptionsResolver;
50
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
51
use Symfony\Contracts\HttpClient\HttpClientInterface;
52
53
/**
54
 * This service handles the form submitting of an attachment and handles things like file uploading and downloading.
55
 */
56
class AttachmentSubmitHandler
57
{
58
    protected AttachmentPathResolver $pathResolver;
59
    protected array $folder_mapping;
60
    protected bool $allow_attachments_downloads;
61
    protected HttpClientInterface $httpClient;
62
    protected MimeTypesInterface $mimeTypes;
63
    protected FileTypeFilterTools $filterTools;
64
65
    protected const BLACKLISTED_EXTENSIONS = ['php', 'phtml', 'php3', 'ph3', 'php4', 'ph4', 'php5', 'ph5', 'phtm', 'sh',
66
        'asp', 'cgi', 'py', 'pl', 'exe', 'aspx', 'js', 'mjs', 'jsp', 'css', 'jar', 'html', 'htm', 'shtm', 'shtml', 'htaccess',
67
        'htpasswd', ''];
68
69
    public function __construct(AttachmentPathResolver $pathResolver, bool $allow_attachments_downloads,
70
                                HttpClientInterface $httpClient, MimeTypesInterface $mimeTypes,
71
        FileTypeFilterTools $filterTools)
72
    {
73
        $this->pathResolver = $pathResolver;
74
        $this->allow_attachments_downloads = $allow_attachments_downloads;
75
        $this->httpClient = $httpClient;
76
        $this->mimeTypes = $mimeTypes;
77
78
        $this->filterTools = $filterTools;
79
80
        //The mapping used to determine which folder will be used for an attachment type
81
        $this->folder_mapping = [
82
            PartAttachment::class => 'part',
83
            AttachmentTypeAttachment::class => 'attachment_type',
84
            CategoryAttachment::class => 'category',
85
            CurrencyAttachment::class => 'currency',
86
            ProjectAttachment::class => 'device',
87
            FootprintAttachment::class => 'footprint',
88
            GroupAttachment::class => 'group',
89
            ManufacturerAttachment::class => 'manufacturer',
90
            MeasurementUnitAttachment::class => 'measurement_unit',
91
            StorelocationAttachment::class => 'storelocation',
92
            SupplierAttachment::class => 'supplier',
93
            UserAttachment::class => 'user',
94
            LabelAttachment::class => 'label_profile',
95
        ];
96
    }
97
98
    /**
99
     * Check if the extension of the uploaded file is allowed for the given attachment type.
100
     * Returns true, if the file is allowed, false if not.
101
     */
102
    public function isValidFileExtension(AttachmentType $attachment_type, UploadedFile $uploadedFile): bool
103
    {
104
        //Only validate if the attachment type has specified an filetype filter:
105
        if (empty($attachment_type->getFiletypeFilter())) {
106
            return true;
107
        }
108
109
        return $this->filterTools->isExtensionAllowed(
110
            $attachment_type->getFiletypeFilter(),
111
            $uploadedFile->getClientOriginalExtension()
112
        );
113
    }
114
115
    /**
116
     * Generates a filename for the given attachment and extension.
117
     * The filename contains a random id, so every time this function is called you get an unique name.
118
     *
119
     * @param Attachment $attachment The attachment that should be used for generating an attachment
120
     * @param string     $extension  The extension that the new file should have (must only contain chars allowed in pathes)
121
     *
122
     * @return string the new filename
123
     */
124
    public function generateAttachmentFilename(Attachment $attachment, string $extension): string
125
    {
126
        //Normalize extension
127
        $extension = transliterator_transliterate(
128
            'Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()',
129
            $extension
130
        );
131
132
        //Use the (sanatized) attachment name as an filename part
133
        $safeName = transliterator_transliterate(
134
            'Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()',
135
            $attachment->getName()
136
        );
137
138
        return $safeName.'-'.uniqid('', false).'.'.$extension;
139
    }
140
141
    /**
142
     * Generates an (absolute) path to a folder where the given attachment should be stored.
143
     *
144
     * @param Attachment $attachment    The attachment that should be used for
145
     * @param bool       $secure_upload True if the file path should be located in a safe location
146
     *
147
     * @return string the absolute path for the attachment folder
148
     */
149
    public function generateAttachmentPath(Attachment $attachment, bool $secure_upload = false): string
150
    {
151
        if ($secure_upload) {
152
            $base_path = $this->pathResolver->getSecurePath();
153
        } else {
154
            $base_path = $this->pathResolver->getMediaPath();
155
        }
156
157
        //Ensure the given attachment class is known to mapping
158
        if (!isset($this->folder_mapping[get_class($attachment)])) {
159
            throw new InvalidArgumentException('The given attachment class is not known! The passed class was: '.get_class($attachment));
160
        }
161
        //Ensure the attachment has an assigned element
162
        if (null === $attachment->getElement()) {
163
            throw new InvalidArgumentException('The given attachment is not assigned to an element! An element is needed to generate a path!');
164
        }
165
166
        //Build path
167
        return
168
            $base_path.DIRECTORY_SEPARATOR //Base path
169
            .$this->folder_mapping[get_class($attachment)].DIRECTORY_SEPARATOR.$attachment->getElement()->getID();
170
    }
171
172
    /**
173
     * Handle the submit of an attachment form.
174
     * This function will move the uploaded file or download the URL file to server, if needed.
175
     *
176
     * @param Attachment        $attachment the attachment that should be used for handling
177
     * @param UploadedFile|null $file       If given, that file will be moved to the right location
178
     * @param array             $options    The options to use with the upload. Here you can specify that an URL should be downloaded,
179
     *                                      or an file should be moved to a secure location.
180
     *
181
     * @return Attachment The attachment with the new filename (same instance as passed $attachment)
182
     */
183
    public function handleFormSubmit(Attachment $attachment, ?UploadedFile $file, array $options = []): Attachment
184
    {
185
        $resolver = new OptionsResolver();
186
        $this->configureOptions($resolver);
187
        $options = $resolver->resolve($options);
188
189
        //When a file is given then upload it, otherwise check if we need to download the URL
190
        if ($file) {
191
            $this->upload($attachment, $file, $options);
192
        } elseif ($options['download_url'] && $attachment->isExternal()) {
193
            $this->downloadURL($attachment, $options);
194
        }
195
196
        //Move the attachment files to secure location (and back) if needed
197
        $this->moveFile($attachment, $options['secure_attachment']);
198
199
        //Rename blacklisted (unsecure) files to a better extension
200
        $this->renameBlacklistedExtensions($attachment);
201
202
        //Check if we should assign this attachment to master picture
203
        //this is only possible if the attachment is new (not yet persisted to DB)
204
        if ($options['become_preview_if_empty'] && null === $attachment->getID() && $attachment->isPicture()) {
205
            $element = $attachment->getElement();
206
            if ($element instanceof AttachmentContainingDBElement && null === $element->getMasterPictureAttachment()) {
207
                $element->setMasterPictureAttachment($attachment);
208
            }
209
        }
210
211
        return $attachment;
212
    }
213
214
    /**
215
     * Rename attachments with an unsafe extension (meaning files which would be runned by a  to a safe one.
216
     * @param Attachment $attachment
217
     * @return Attachment
218
     */
219
    protected function renameBlacklistedExtensions(Attachment $attachment): Attachment
220
    {
221
        //We can not do anything on builtins or external ressources
222
        if ($attachment->isBuiltIn() || $attachment->isExternal()) {
223
            return $attachment;
224
        }
225
226
        //Determine the old filepath
227
        $old_path = $this->pathResolver->placeholderToRealPath($attachment->getPath());
228
        if (empty($old_path) || !file_exists($old_path)) {
229
            return $attachment;
230
        }
231
        $filename = basename($old_path);
232
        $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
0 ignored issues
show
Bug introduced by
It seems like pathinfo($filename, App\...nts\PATHINFO_EXTENSION) can also be of type array; however, parameter $string of strtolower() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

232
        $ext = strtolower(/** @scrutinizer ignore-type */ pathinfo($filename, PATHINFO_EXTENSION));
Loading history...
233
234
235
        //Check if the extension is blacklisted and replace the file extension with txt if needed
236
        if(in_array($ext, self::BLACKLISTED_EXTENSIONS)) {
237
            $new_path = $this->generateAttachmentPath($attachment, $attachment->isSecure())
238
            .DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, 'txt');
239
240
            //Move file to new directory
241
            $fs = new Filesystem();
242
            $fs->rename($old_path, $new_path);
243
244
            //Update the attachment
245
            $attachment->setPath($this->pathResolver->realPathToPlaceholder($new_path));
0 ignored issues
show
Bug introduced by
It seems like $this->pathResolver->rea...oPlaceholder($new_path) can also be of type null; however, parameter $path of App\Entity\Attachments\Attachment::setPath() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

245
            $attachment->setPath(/** @scrutinizer ignore-type */ $this->pathResolver->realPathToPlaceholder($new_path));
Loading history...
246
        }
247
248
249
        return $attachment;
250
    }
251
252
    protected function configureOptions(OptionsResolver $resolver): void
253
    {
254
        $resolver->setDefaults([
255
            //If no preview image was set yet, the new uploaded file will become the preview image
256
            'become_preview_if_empty' => true,
257
            //When an URL is given download the URL
258
            'download_url' => false,
259
            'secure_attachment' => false,
260
        ]);
261
    }
262
263
    /**
264
     * Move the given attachment to secure location (or back to public folder) if needed.
265
     *
266
     * @param Attachment $attachment      the attachment for which the file should be moved
267
     * @param bool       $secure_location this value determines, if the attachment is moved to the secure or public folder
268
     *
269
     * @return Attachment The attachment with the updated filepath
270
     */
271
    protected function moveFile(Attachment $attachment, bool $secure_location): Attachment
272
    {
273
        //We can not do anything on builtins or external ressources
274
        if ($attachment->isBuiltIn() || $attachment->isExternal()) {
275
            return $attachment;
276
        }
277
278
        //Check if we need to move the file
279
        if ($secure_location === $attachment->isSecure()) {
280
            return $attachment;
281
        }
282
283
        //Determine the old filepath
284
        $old_path = $this->pathResolver->placeholderToRealPath($attachment->getPath());
285
        if (!file_exists($old_path)) {
0 ignored issues
show
Bug introduced by
It seems like $old_path can also be of type null; however, parameter $filename of file_exists() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

285
        if (!file_exists(/** @scrutinizer ignore-type */ $old_path)) {
Loading history...
286
            return $attachment;
287
        }
288
289
        $filename = basename($old_path);
0 ignored issues
show
Bug introduced by
It seems like $old_path can also be of type null; however, parameter $path of basename() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

289
        $filename = basename(/** @scrutinizer ignore-type */ $old_path);
Loading history...
290
        //If the basename is not one of the new unique on, we have to save the old filename
291
        if (!preg_match('#\w+-\w{13}\.#', $filename)) {
292
            //Save filename to attachment field
293
            $attachment->setFilename($attachment->getFilename());
294
        }
295
296
        $ext = pathinfo($filename, PATHINFO_EXTENSION);
297
        $new_path = $this->generateAttachmentPath($attachment, $secure_location)
298
            .DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, $ext);
0 ignored issues
show
Bug introduced by
It seems like $ext can also be of type array; however, parameter $extension of App\Services\Attachments...ateAttachmentFilename() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

298
            .DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, /** @scrutinizer ignore-type */ $ext);
Loading history...
299
300
        //Move file to new directory
301
        $fs = new Filesystem();
302
        //Ensure that the new path exists
303
        $fs->mkdir(dirname($new_path));
304
        $fs->rename($old_path, $new_path);
0 ignored issues
show
Bug introduced by
It seems like $old_path can also be of type null; however, parameter $origin of Symfony\Component\Filesystem\Filesystem::rename() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

304
        $fs->rename(/** @scrutinizer ignore-type */ $old_path, $new_path);
Loading history...
305
306
        //Save info to attachment entity
307
        $new_path = $this->pathResolver->realPathToPlaceholder($new_path);
308
        $attachment->setPath($new_path);
0 ignored issues
show
Bug introduced by
It seems like $new_path can also be of type null; however, parameter $path of App\Entity\Attachments\Attachment::setPath() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

308
        $attachment->setPath(/** @scrutinizer ignore-type */ $new_path);
Loading history...
309
310
        return $attachment;
311
    }
312
313
    /**
314
     * Download the URL set in the attachment and save it on the server.
315
     *
316
     * @param array $options The options from the handleFormSubmit function
317
     *
318
     * @return Attachment The attachment with the new filepath
319
     */
320
    protected function downloadURL(Attachment $attachment, array $options): Attachment
321
    {
322
        //Check if we are allowed to download files
323
        if (!$this->allow_attachments_downloads) {
324
            throw new RuntimeException('Download of attachments is not allowed!');
325
        }
326
327
        $url = $attachment->getURL();
328
329
        $fs = new Filesystem();
330
        $attachment_folder = $this->generateAttachmentPath($attachment, $options['secure_attachment']);
331
        $tmp_path = $attachment_folder.DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, 'tmp');
332
333
        try {
334
            $response = $this->httpClient->request('GET', $url, [
0 ignored issues
show
Bug introduced by
It seems like $url can also be of type null; however, parameter $url of Symfony\Contracts\HttpCl...entInterface::request() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

334
            $response = $this->httpClient->request('GET', /** @scrutinizer ignore-type */ $url, [
Loading history...
335
                'buffer' => false,
336
            ]);
337
338
            if (200 !== $response->getStatusCode()) {
339
                throw new AttachmentDownloadException('Status code: '.$response->getStatusCode());
340
            }
341
342
            //Open a temporary file in the attachment folder
343
            $fs->mkdir($attachment_folder);
344
            $fileHandler = fopen($tmp_path, 'wb');
345
            //Write the downloaded data to file
346
            foreach ($this->httpClient->stream($response) as $chunk) {
347
                fwrite($fileHandler, $chunk->getContent());
348
            }
349
            fclose($fileHandler);
350
351
            //File download should be finished here, so determine the new filename and extension
352
            $headers = $response->getHeaders();
353
            //Try to determine an filename
354
            $filename = '';
355
356
            //If an content disposition header was set try to extract the filename out of it
357
            if (isset($headers['content-disposition'])) {
358
                $tmp = [];
359
                preg_match('/[^;\\n=]*=([\'\"])*(.*)(?(1)\1|)/', $headers['content-disposition'][0], $tmp);
360
                $filename = $tmp[2];
361
            }
362
363
            //If we dont know filename yet, try to determine it out of url
364
            if ('' === $filename) {
365
                $filename = basename(parse_url($url, PHP_URL_PATH));
366
            }
367
368
            //Set original file
369
            $attachment->setFilename($filename);
370
371
            //Check if we have a extension given
372
            $pathinfo = pathinfo($filename);
373
            if (!empty($pathinfo['extension'])) {
374
                $new_ext = $pathinfo['extension'];
375
            } else { //Otherwise we have to guess the extension for the new file, based on its content
376
                $new_ext = $this->mimeTypes->getExtensions($this->mimeTypes->guessMimeType($tmp_path))[0] ?? 'tmp';
0 ignored issues
show
Bug introduced by
It seems like $this->mimeTypes->guessMimeType($tmp_path) can also be of type null; however, parameter $mimeType of Symfony\Component\Mime\M...erface::getExtensions() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

376
                $new_ext = $this->mimeTypes->getExtensions(/** @scrutinizer ignore-type */ $this->mimeTypes->guessMimeType($tmp_path))[0] ?? 'tmp';
Loading history...
377
            }
378
379
            //Rename the file to its new name and save path to attachment entity
380
            $new_path = $attachment_folder.DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, $new_ext);
381
            $fs->rename($tmp_path, $new_path);
382
383
            //Make our file path relative to %BASE%
384
            $new_path = $this->pathResolver->realPathToPlaceholder($new_path);
385
            //Save the path to the attachment
386
            $attachment->setPath($new_path);
0 ignored issues
show
Bug introduced by
It seems like $new_path can also be of type null; however, parameter $path of App\Entity\Attachments\Attachment::setPath() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

386
            $attachment->setPath(/** @scrutinizer ignore-type */ $new_path);
Loading history...
387
        } catch (TransportExceptionInterface $transportExceptionInterface) {
388
            throw new AttachmentDownloadException('Transport error!');
389
        }
390
391
        return $attachment;
392
    }
393
394
    /**
395
     * Moves the given uploaded file to a permanent place and saves it into the attachment.
396
     *
397
     * @param Attachment   $attachment The attachment in which the file should be saved
398
     * @param UploadedFile $file       The file which was uploaded
399
     * @param array        $options    The options from the handleFormSubmit function
400
     *
401
     * @return Attachment The attachment with the new filepath
402
     */
403
    protected function upload(Attachment $attachment, UploadedFile $file, array $options): Attachment
404
    {
405
        //Move our temporay attachment to its final location
406
        $file_path = $file->move(
407
            $this->generateAttachmentPath($attachment, $options['secure_attachment']),
408
            $this->generateAttachmentFilename($attachment, $file->getClientOriginalExtension())
409
        )->getRealPath();
410
411
        //Make our file path relative to %BASE%
412
        $file_path = $this->pathResolver->realPathToPlaceholder($file_path);
413
        //Save the path to the attachment
414
        $attachment->setPath($file_path);
0 ignored issues
show
Bug introduced by
It seems like $file_path can also be of type null; however, parameter $path of App\Entity\Attachments\Attachment::setPath() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

414
        $attachment->setPath(/** @scrutinizer ignore-type */ $file_path);
Loading history...
415
        //And save original filename
416
        $attachment->setFilename($file->getClientOriginalName());
417
418
        return $attachment;
419
    }
420
}
421