Passed
Push — master ( f0d0a7...1b0620 )
by Jan
10:32
created

AttachmentSubmitHandler::isValidFileExtension()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 5
c 0
b 0
f 0
nc 2
nop 2
dl 0
loc 10
rs 10
1
<?php
2
/**
3
 * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
4
 *
5
 * Copyright (C) 2019 - 2020 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
/**
24
 * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
25
 *
26
 * Copyright (C) 2019 Jan Böhmer (https://github.com/jbtronics)
27
 *
28
 * This program is free software; you can redistribute it and/or
29
 * modify it under the terms of the GNU General Public License
30
 * as published by the Free Software Foundation; either version 2
31
 * of the License, or (at your option) any later version.
32
 *
33
 * This program is distributed in the hope that it will be useful,
34
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
35
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
36
 * GNU General Public License for more details.
37
 *
38
 * You should have received a copy of the GNU General Public License
39
 * along with this program; if not, write to the Free Software
40
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
41
 */
42
43
namespace App\Services\Attachments;
44
45
use App\Entity\Attachments\Attachment;
46
use App\Entity\Attachments\AttachmentContainingDBElement;
47
use App\Entity\Attachments\AttachmentType;
48
use App\Entity\Attachments\AttachmentTypeAttachment;
49
use App\Entity\Attachments\CategoryAttachment;
50
use App\Entity\Attachments\CurrencyAttachment;
51
use App\Entity\Attachments\DeviceAttachment;
52
use App\Entity\Attachments\FootprintAttachment;
53
use App\Entity\Attachments\GroupAttachment;
54
use App\Entity\Attachments\ManufacturerAttachment;
55
use App\Entity\Attachments\MeasurementUnitAttachment;
56
use App\Entity\Attachments\PartAttachment;
57
use App\Entity\Attachments\StorelocationAttachment;
58
use App\Entity\Attachments\SupplierAttachment;
59
use App\Entity\Attachments\UserAttachment;
60
use App\Exceptions\AttachmentDownloadException;
61
use Symfony\Component\Form\FormInterface;
62
use const DIRECTORY_SEPARATOR;
63
use function get_class;
64
use InvalidArgumentException;
65
use RuntimeException;
66
use Symfony\Component\Filesystem\Filesystem;
67
use Symfony\Component\HttpFoundation\File\UploadedFile;
68
use Symfony\Component\Mime\MimeTypesInterface;
69
use Symfony\Component\OptionsResolver\OptionsResolver;
70
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
71
use Symfony\Contracts\HttpClient\HttpClientInterface;
72
73
/**
74
 * This service handles the form submitting of an attachment and handles things like file uploading and downloading.
75
 */
76
class AttachmentSubmitHandler
77
{
78
    protected $pathResolver;
79
    protected $folder_mapping;
80
    protected $allow_attachments_downloads;
81
    protected $httpClient;
82
    protected $mimeTypes;
83
    protected $filterTools;
84
85
86
    public function __construct(AttachmentPathResolver $pathResolver, bool $allow_attachments_downloads,
87
                                HttpClientInterface $httpClient, MimeTypesInterface $mimeTypes,
88
        FileTypeFilterTools $filterTools)
89
    {
90
        $this->pathResolver = $pathResolver;
91
        $this->allow_attachments_downloads = $allow_attachments_downloads;
92
        $this->httpClient = $httpClient;
93
        $this->mimeTypes = $mimeTypes;
94
95
        $this->filterTools = $filterTools;
96
97
        //The mapping used to determine which folder will be used for an attachment type
98
        $this->folder_mapping = [
99
            PartAttachment::class => 'part',
100
            AttachmentTypeAttachment::class => 'attachment_type',
101
            CategoryAttachment::class => 'category',
102
            CurrencyAttachment::class => 'currency',
103
            DeviceAttachment::class => 'device',
104
            FootprintAttachment::class => 'footprint',
105
            GroupAttachment::class => 'group',
106
            ManufacturerAttachment::class => 'manufacturer',
107
            MeasurementUnitAttachment::class => 'measurement_unit',
108
            StorelocationAttachment::class => 'storelocation',
109
            SupplierAttachment::class => 'supplier',
110
            UserAttachment::class => 'user',
111
        ];
112
    }
113
114
    /**
115
     * Check if the extension of the uploaded file is allowed for the given attachment type.
116
     * Returns true, if the file is allowed, false if not.
117
     * @param  Attachment  $attachment
118
     * @param  UploadedFile  $uploadedFile
119
     * @return bool
120
     */
121
    public function isValidFileExtension(AttachmentType $attachment_type, UploadedFile $uploadedFile): bool
122
    {
123
        //Only validate if the attachment type has specified an filetype filter:
124
        if (empty($attachment_type->getFiletypeFilter())) {
125
            return true;
126
        }
127
128
        return $this->filterTools->isExtensionAllowed(
129
            $attachment_type->getFiletypeFilter(),
130
            $uploadedFile->getClientOriginalExtension()
131
        );
132
    }
133
134
    /**
135
     * Generates a filename for the given attachment and extension.
136
     * The filename contains a random id, so every time this function is called you get an unique name.
137
     *
138
     * @param Attachment $attachment The attachment that should be used for generating an attachment
139
     * @param string     $extension  The extension that the new file should have (must only contain chars allowed in pathes)
140
     *
141
     * @return string the new filename
142
     */
143
    public function generateAttachmentFilename(Attachment $attachment, string $extension): string
144
    {
145
        //Normalize extension
146
        $extension = transliterator_transliterate(
147
            'Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()',
148
            $extension
149
        );
150
151
        //Use the (sanatized) attachment name as an filename part
152
        $safeName = transliterator_transliterate(
153
            'Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()',
154
            $attachment->getName()
155
        );
156
157
        return $safeName.'-'.uniqid('', false).'.'.$extension;
158
    }
159
160
    /**
161
     * Generates an (absolute) path to a folder where the given attachment should be stored.
162
     *
163
     * @param Attachment $attachment    The attachment that should be used for
164
     * @param bool       $secure_upload True if the file path should be located in a safe location
165
     *
166
     * @return string the absolute path for the attachment folder
167
     */
168
    public function generateAttachmentPath(Attachment $attachment, bool $secure_upload = false): string
169
    {
170
        if ($secure_upload) {
171
            $base_path = $this->pathResolver->getSecurePath();
172
        } else {
173
            $base_path = $this->pathResolver->getMediaPath();
174
        }
175
176
        //Ensure the given attachment class is known to mapping
177
        if (! isset($this->folder_mapping[get_class($attachment)])) {
178
            throw new InvalidArgumentException('The given attachment class is not known! The passed class was: '.get_class($attachment));
179
        }
180
        //Ensure the attachment has an assigned element
181
        if (null === $attachment->getElement()) {
182
            throw new InvalidArgumentException('The given attachment is not assigned to an element! An element is needed to generate a path!');
183
        }
184
185
        //Build path
186
        return
187
            $base_path.DIRECTORY_SEPARATOR //Base path
188
            .$this->folder_mapping[get_class($attachment)].DIRECTORY_SEPARATOR.$attachment->getElement()->getID();
189
    }
190
191
    /**
192
     * Handle the submit of an attachment form.
193
     * This function will move the uploaded file or download the URL file to server, if needed.
194
     *
195
     * @param Attachment        $attachment the attachment that should be used for handling
196
     * @param UploadedFile|null $file       If given, that file will be moved to the right location
197
     * @param array             $options    The options to use with the upload. Here you can specify that an URL should be downloaded,
198
     *                                      or an file should be moved to a secure location.
199
     *
200
     * @return Attachment The attachment with the new filename (same instance as passed $attachment)
201
     */
202
    public function handleFormSubmit(Attachment $attachment, ?UploadedFile $file, array $options = []): Attachment
203
    {
204
        $resolver = new OptionsResolver();
205
        $this->configureOptions($resolver);
206
        $options = $resolver->resolve($options);
207
208
        //When a file is given then upload it, otherwise check if we need to download the URL
209
        if ($file) {
210
            $this->upload($attachment, $file, $options);
211
        } elseif ($options['download_url'] && $attachment->isExternal()) {
212
            $this->downloadURL($attachment, $options);
213
        }
214
215
        //Move the attachment files to secure location (and back) if needed
216
        $this->moveFile($attachment, $options['secure_attachment']);
217
218
        //Check if we should assign this attachment to master picture
219
        //this is only possible if the attachment is new (not yet persisted to DB)
220
        if ($options['become_preview_if_empty'] && null === $attachment->getID() && $attachment->isPicture()) {
221
            $element = $attachment->getElement();
222
            if ($element instanceof AttachmentContainingDBElement && null === $element->getMasterPictureAttachment()) {
223
                $element->setMasterPictureAttachment($attachment);
224
            }
225
        }
226
227
        return $attachment;
228
    }
229
230
    protected function configureOptions(OptionsResolver $resolver): void
231
    {
232
        $resolver->setDefaults([
233
            //If no preview image was set yet, the new uploaded file will become the preview image
234
            'become_preview_if_empty' => true,
235
            //When an URL is given download the URL
236
            'download_url' => false,
237
            'secure_attachment' => false,
238
        ]);
239
    }
240
241
    /**
242
     * Move the given attachment to secure location (or back to public folder) if needed.
243
     *
244
     * @param Attachment $attachment      the attachment for which the file should be moved
245
     * @param bool       $secure_location this value determines, if the attachment is moved to the secure or public folder
246
     *
247
     * @return Attachment The attachment with the updated filepath
248
     */
249
    protected function moveFile(Attachment $attachment, bool $secure_location): Attachment
250
    {
251
        //We can not do anything on builtins or external ressources
252
        if ($attachment->isBuiltIn() || $attachment->isExternal()) {
253
            return $attachment;
254
        }
255
256
        //Check if we need to move the file
257
        if ($secure_location === $attachment->isSecure()) {
258
            return $attachment;
259
        }
260
261
        //Determine the old filepath
262
        $old_path = $this->pathResolver->placeholderToRealPath($attachment->getPath());
263
        if (! file_exists($old_path)) {
264
            return $attachment;
265
        }
266
267
        $filename = basename($old_path);
268
        //If the basename is not one of the new unique on, we have to save the old filename
269
        if (! preg_match('#\w+-\w{13}\.#', $filename)) {
270
            //Save filename to attachment field
271
            $attachment->setFilename($attachment->getFilename());
272
        }
273
274
        $ext = pathinfo($filename, PATHINFO_EXTENSION);
275
        $new_path = $this->generateAttachmentPath($attachment, $secure_location)
276
            .DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, $ext);
277
278
        //Move file to new directory
279
        $fs = new Filesystem();
280
        //Ensure that the new path exists
281
        $fs->mkdir(dirname($new_path));
282
        $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

282
        $fs->rename(/** @scrutinizer ignore-type */ $old_path, $new_path);
Loading history...
283
284
        //Save info to attachment entity
285
        $new_path = $this->pathResolver->realPathToPlaceholder($new_path);
286
        $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

286
        $attachment->setPath(/** @scrutinizer ignore-type */ $new_path);
Loading history...
287
288
        return $attachment;
289
    }
290
291
    /**
292
     * Download the URL set in the attachment and save it on the server.
293
     *
294
     * @param array $options The options from the handleFormSubmit function
295
     *
296
     * @return Attachment The attachment with the new filepath
297
     */
298
    protected function downloadURL(Attachment $attachment, array $options): Attachment
299
    {
300
        //Check if we are allowed to download files
301
        if (! $this->allow_attachments_downloads) {
302
            throw new RuntimeException('Download of attachments is not allowed!');
303
        }
304
305
        $url = $attachment->getURL();
306
307
        $fs = new Filesystem();
308
        $attachment_folder = $this->generateAttachmentPath($attachment, $options['secure_attachment']);
309
        $tmp_path = $attachment_folder.DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, 'tmp');
310
311
        try {
312
            $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

312
            $response = $this->httpClient->request('GET', /** @scrutinizer ignore-type */ $url, [
Loading history...
313
                'buffer' => false,
314
            ]);
315
316
            if (200 !== $response->getStatusCode()) {
317
                throw new AttachmentDownloadException('Status code: '.$response->getStatusCode());
318
            }
319
320
            //Open a temporary file in the attachment folder
321
            $fs->mkdir($attachment_folder);
322
            $fileHandler = fopen($tmp_path, 'wb');
323
            //Write the downloaded data to file
324
            foreach ($this->httpClient->stream($response) as $chunk) {
325
                fwrite($fileHandler, $chunk->getContent());
0 ignored issues
show
Bug introduced by
It seems like $fileHandler can also be of type false; however, parameter $handle of fwrite() does only seem to accept resource, 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

325
                fwrite(/** @scrutinizer ignore-type */ $fileHandler, $chunk->getContent());
Loading history...
326
            }
327
            fclose($fileHandler);
0 ignored issues
show
Bug introduced by
It seems like $fileHandler can also be of type false; however, parameter $handle of fclose() does only seem to accept resource, 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

327
            fclose(/** @scrutinizer ignore-type */ $fileHandler);
Loading history...
328
329
            //File download should be finished here, so determine the new filename and extension
330
            $headers = $response->getHeaders();
331
            //Try to determine an filename
332
            $filename = '';
333
334
            //If an content disposition header was set try to extract the filename out of it
335
            if (isset($headers['content-disposition'])) {
336
                $tmp = [];
337
                preg_match('/[^;\\n=]*=([\'\"])*(.*)(?(1)\1|)/', $headers['content-disposition'][0], $tmp);
338
                $filename = $tmp[2];
339
            }
340
341
            //If we dont know filename yet, try to determine it out of url
342
            if ('' === $filename) {
343
                $filename = basename(parse_url($url, PHP_URL_PATH));
344
            }
345
346
            //Set original file
347
            $attachment->setFilename($filename);
348
349
            //Check if we have a extension given
350
            $pathinfo = pathinfo($filename);
351
            if (! empty($pathinfo['extension'])) {
352
                $new_ext = $pathinfo['extension'];
353
            } else { //Otherwise we have to guess the extension for the new file, based on its content
354
                $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

354
                $new_ext = $this->mimeTypes->getExtensions(/** @scrutinizer ignore-type */ $this->mimeTypes->guessMimeType($tmp_path))[0] ?? 'tmp';
Loading history...
355
            }
356
357
            //Rename the file to its new name and save path to attachment entity
358
            $new_path = $attachment_folder.DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, $new_ext);
359
            $fs->rename($tmp_path, $new_path);
360
361
            //Make our file path relative to %BASE%
362
            $new_path = $this->pathResolver->realPathToPlaceholder($new_path);
363
            //Save the path to the attachment
364
            $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

364
            $attachment->setPath(/** @scrutinizer ignore-type */ $new_path);
Loading history...
365
        } catch (TransportExceptionInterface $transportExceptionInterface) {
366
            throw new AttachmentDownloadException('Transport error!');
367
        }
368
369
        return $attachment;
370
    }
371
372
    /**
373
     * Moves the given uploaded file to a permanent place and saves it into the attachment.
374
     *
375
     * @param Attachment   $attachment The attachment in which the file should be saved
376
     * @param UploadedFile $file       The file which was uploaded
377
     * @param array        $options    The options from the handleFormSubmit function
378
     *
379
     * @return Attachment The attachment with the new filepath
380
     */
381
    protected function upload(Attachment $attachment, UploadedFile $file, array $options): Attachment
382
    {
383
        //Move our temporay attachment to its final location
384
        $file_path = $file->move(
385
            $this->generateAttachmentPath($attachment, $options['secure_attachment']),
386
            $this->generateAttachmentFilename($attachment, $file->getClientOriginalExtension())
387
        )->getRealPath();
388
389
        //Make our file path relative to %BASE%
390
        $file_path = $this->pathResolver->realPathToPlaceholder($file_path);
391
        //Save the path to the attachment
392
        $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

392
        $attachment->setPath(/** @scrutinizer ignore-type */ $file_path);
Loading history...
393
        //And save original filename
394
        $attachment->setFilename($file->getClientOriginalName());
395
396
        return $attachment;
397
    }
398
}
399