Completed
Push — master ( 9385d2...63dc22 )
by Jan
04:27
created

AttachmentSubmitHandler::moveFile()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 38
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

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

238
        $attachment->setPath(/** @scrutinizer ignore-type */ $new_path);
Loading history...
239
240
        return $attachment;
241
    }
242
243
    /**
244
     * Download the URL set in the attachment and save it on the server
245
     * @param Attachment $attachment
246
     * @param array $options The options from the handleFormSubmit function
247
     * @return Attachment The attachment with the new filepath
248
     */
249
    protected function downloadURL(Attachment $attachment, array $options) : Attachment
250
    {
251
        //Check if we are allowed to download files
252
        if (!$this->allow_attachments_downloads) {
253
            throw new \RuntimeException('Download of attachments is not allowed!');
254
        }
255
256
        $url = $attachment->getURL();
257
258
        $fs = new Filesystem();
259
        $attachment_folder = $this->generateAttachmentPath($attachment, $options['secure_attachment']);
260
        $tmp_path = $attachment_folder . DIRECTORY_SEPARATOR . $this->generateAttachmentFilename($attachment, 'tmp');
261
262
        try {
263
            $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

263
            $response = $this->httpClient->request('GET', /** @scrutinizer ignore-type */ $url, [
Loading history...
264
                'buffer' => false,
265
            ]);
266
267
            if (200 !== $response->getStatusCode()) {
268
                throw new AttachmentDownloadException('Status code: ' . $response->getStatusCode());
269
            }
270
271
            //Open a temporary file in the attachment folder
272
            $fs->mkdir($attachment_folder);
273
            $fileHandler = fopen($tmp_path, 'wb');
274
            //Write the downloaded data to file
275
            foreach ($this->httpClient->stream($response) as $chunk) {
276
                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

276
                fwrite(/** @scrutinizer ignore-type */ $fileHandler, $chunk->getContent());
Loading history...
277
            }
278
            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

278
            fclose(/** @scrutinizer ignore-type */ $fileHandler);
Loading history...
279
280
            //File download should be finished here, so determine the new filename and extension
281
            $headers = $response->getHeaders();
282
            //Try to determine an filename
283
            $filename = "";
284
285
            //If an content disposition header was set try to extract the filename out of it
286
            if (isset($headers['content-disposition'])) {
287
                $tmp = [];
288
                preg_match('/[^;\\n=]*=([\'\"])*(.*)(?(1)\1|)/', $headers['content-disposition'][0], $tmp);
289
                $filename = $tmp[2];
290
            }
291
292
            //If we dont know filename yet, try to determine it out of url
293
            if ($filename === "") {
294
                $filename = basename(parse_url($url, PHP_URL_PATH));
295
            }
296
297
            //Set original file
298
            $attachment->setFilename($filename);
299
300
            //Check if we have a extension given
301
            $pathinfo = pathinfo($filename);
302
            if (!empty($pathinfo['extension'])) {
303
                $new_ext = $pathinfo['extension'];
304
            } else { //Otherwise we have to guess the extension for the new file, based on its content
305
                $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

305
                $new_ext = $this->mimeTypes->getExtensions(/** @scrutinizer ignore-type */ $this->mimeTypes->guessMimeType($tmp_path))[0] ?? 'tmp';
Loading history...
306
            }
307
308
            //Rename the file to its new name and save path to attachment entity
309
            $new_path = $attachment_folder . DIRECTORY_SEPARATOR . $this->generateAttachmentFilename($attachment, $new_ext);
310
            $fs->rename($tmp_path, $new_path);
311
312
            //Make our file path relative to %BASE%
313
            $new_path = $this->pathResolver->realPathToPlaceholder($new_path);
314
            //Save the path to the attachment
315
            $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

315
            $attachment->setPath(/** @scrutinizer ignore-type */ $new_path);
Loading history...
316
317
        } catch (TransportExceptionInterface $exception) {
318
            throw new AttachmentDownloadException('Transport error!');
319
        }
320
321
        return $attachment;
322
    }
323
324
    /**
325
     * Moves the given uploaded file to a permanent place and saves it into the attachment
326
     * @param Attachment $attachment The attachment in which the file should be saved
327
     * @param UploadedFile $file The file which was uploaded
328
     * @param array $options The options from the handleFormSubmit function
329
     * @return Attachment The attachment with the new filepath
330
     */
331
    protected function upload(Attachment $attachment, UploadedFile $file, array $options) : Attachment
332
    {
333
        //Move our temporay attachment to its final location
334
        $file_path = $file->move(
335
            $this->generateAttachmentPath($attachment, $options['secure_attachment']),
336
            $this->generateAttachmentFilename($attachment, $file->getClientOriginalExtension())
337
        )->getRealPath();
338
339
        //Make our file path relative to %BASE%
340
        $file_path = $this->pathResolver->realPathToPlaceholder($file_path);
341
        //Save the path to the attachment
342
        $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

342
        $attachment->setPath(/** @scrutinizer ignore-type */ $file_path);
Loading history...
343
        //And save original filename
344
        $attachment->setFilename($file->getClientOriginalName());
345
346
        return $attachment;
347
    }
348
}