Passed
Push — trunk ( 36db4a...0fa3f8 )
by Christian
16:05 queued 14s
created

FileSaver::doRenameMedia()   B

Complexity

Conditions 6
Paths 17

Size

Total Lines 45
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 28
nc 17
nop 3
dl 0
loc 45
rs 8.8497
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
3
namespace Shopware\Core\Content\Media\File;
4
5
use League\Flysystem\FilesystemOperator;
6
use League\Flysystem\UnableToDeleteFile;
7
use Shopware\Core\Content\Media\Aggregate\MediaThumbnail\MediaThumbnailEntity;
8
use Shopware\Core\Content\Media\Core\Application\AbstractMediaPathStrategy;
9
use Shopware\Core\Content\Media\Core\Event\UpdateMediaPathEvent;
10
use Shopware\Core\Content\Media\Event\MediaFileExtensionWhitelistEvent;
11
use Shopware\Core\Content\Media\Infrastructure\Path\SqlMediaLocationBuilder;
12
use Shopware\Core\Content\Media\MediaCollection;
13
use Shopware\Core\Content\Media\MediaEntity;
14
use Shopware\Core\Content\Media\MediaException;
15
use Shopware\Core\Content\Media\MediaType\MediaType;
16
use Shopware\Core\Content\Media\Message\GenerateThumbnailsMessage;
17
use Shopware\Core\Content\Media\Metadata\MetadataLoader;
18
use Shopware\Core\Content\Media\Thumbnail\ThumbnailService;
19
use Shopware\Core\Content\Media\TypeDetector\TypeDetector;
20
use Shopware\Core\Framework\Api\Context\AdminApiSource;
21
use Shopware\Core\Framework\Context;
22
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
23
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
24
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
25
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\MultiFilter;
26
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\NotFilter;
27
use Shopware\Core\Framework\Feature;
28
use Shopware\Core\Framework\Log\Package;
29
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
30
use Symfony\Component\Messenger\MessageBusInterface;
31
32
#[Package('buyers-experience')]
33
class FileSaver
34
{
35
    private readonly FileNameValidator $fileNameValidator;
36
37
    /**
38
     * @internal
39
     *
40
     * @param list<string> $allowedExtensions
41
     * @param list<string> $privateAllowedExtensions
42
     */
43
    public function __construct(
44
        private readonly EntityRepository $mediaRepository,
45
        private readonly FilesystemOperator $filesystemPublic,
46
        private readonly FilesystemOperator $filesystemPrivate,
47
        private readonly ThumbnailService $thumbnailService,
48
        private readonly MetadataLoader $metadataLoader,
49
        private readonly TypeDetector $typeDetector,
50
        private readonly MessageBusInterface $messageBus,
51
        private readonly EventDispatcherInterface $eventDispatcher,
52
        private readonly SqlMediaLocationBuilder $locationBuilder,
53
        private readonly AbstractMediaPathStrategy $mediaPathStrategy,
54
        private readonly array $allowedExtensions,
55
        private readonly array $privateAllowedExtensions
56
    ) {
57
        $this->fileNameValidator = new FileNameValidator();
58
    }
59
60
    /**
61
     * @throws MediaException
62
     */
63
    public function persistFileToMedia(
64
        MediaFile $mediaFile,
65
        string $destination,
66
        string $mediaId,
67
        Context $context
68
    ): void {
69
        $currentMedia = $this->findMediaById($mediaId, $context);
70
        $destination = $this->validateFileName($destination);
71
        $this->ensureFileNameIsUnique(
72
            $currentMedia,
73
            $destination,
74
            $mediaFile->getFileExtension(),
75
            $context
76
        );
77
78
        $this->validateFileExtension($mediaFile, $mediaId, $currentMedia->isPrivate());
79
80
        $this->removeOldMediaData($currentMedia, $context);
81
82
        $mediaType = $this->typeDetector->detect($mediaFile);
83
84
        $metaData = $this->metadataLoader->loadFromFile($mediaFile, $mediaType);
85
86
        $media = $this->updateMediaEntity(
87
            $mediaFile,
88
            $destination,
89
            $currentMedia,
90
            $metaData,
91
            $mediaType,
92
            $context
93
        );
94
95
        $this->saveFileToMediaDir($mediaFile, $media);
96
97
        $message = new GenerateThumbnailsMessage();
98
        $message->setMediaIds([$mediaId]);
99
100
        if (Feature::isActive('v6.6.0.0')) {
101
            $message->setContext($context);
102
        } else {
103
            $message->withContext($context);
104
        }
105
106
        $this->messageBus->dispatch($message);
107
    }
108
109
    public function renameMedia(string $mediaId, string $destination, Context $context): void
110
    {
111
        $destination = $this->validateFileName($destination);
112
        $currentMedia = $this->findMediaById($mediaId, $context);
113
        $fileExtension = $currentMedia->getFileExtension();
114
115
        if (!$currentMedia->hasFile() || !$fileExtension) {
116
            throw MediaException::missingFile($mediaId);
117
        }
118
119
        if ($destination === $currentMedia->getFileName()) {
120
            return;
121
        }
122
123
        $this->ensureFileNameIsUnique(
124
            $currentMedia,
125
            $destination,
126
            $fileExtension,
127
            $context
128
        );
129
130
        $this->doRenameMedia($currentMedia, $destination, $context);
131
    }
132
133
    private function doRenameMedia(MediaEntity $media, string $destination, Context $context): void
134
    {
135
        $path = $this->getNewMediaPath($media, $destination);
136
137
        $thumbnails = $this->getNewThumbnailPaths($media, $destination);
138
139
        try {
140
            $renamedFiles = $this->renameFile(
141
                $media->getPath(),
142
                $path,
143
                $this->getFileSystem($media)
144
            );
145
        } catch (\Exception) {
146
            throw MediaException::couldNotRenameFile($media->getId(), (string) $media->getFileName());
147
        }
148
149
        foreach ($media->getThumbnails() ?? [] as $thumbnail) {
150
            try {
151
                $thumbnailDestination = $thumbnails[$thumbnail->getUniqueIdentifier()];
152
153
                $renamedFiles = [...$renamedFiles, ...$this->renameThumbnail($thumbnail, $media, $thumbnailDestination)];
154
            } catch (\Exception) {
155
                $this->rollbackRenameAction($media, $renamedFiles);
156
            }
157
        }
158
159
        $updateData = [
160
            'id' => $media->getId(),
161
            'fileName' => $destination,
162
            'path' => $path,
163
        ];
164
165
        if (!empty($thumbnails)) {
166
            $updateData['thumbnails'] = array_map(function ($id, $path) {
167
                return ['id' => $id, 'path' => $path];
168
            }, array_keys($thumbnails), $thumbnails);
169
        }
170
171
        try {
172
            $context->scope(Context::SYSTEM_SCOPE, function (Context $context) use ($updateData): void {
173
                // also triggers the indexing, so that the thumbnails_ro is recalculate
174
                $this->mediaRepository->update([$updateData], $context);
175
            });
176
        } catch (\Exception) {
177
            $this->rollbackRenameAction($media, $renamedFiles);
178
        }
179
    }
180
181
    /**
182
     * @return array<string, string>
183
     */
184
    private function renameThumbnail(
185
        MediaThumbnailEntity $thumbnail,
186
        MediaEntity $currentMedia,
187
        string $destination
188
    ): array {
189
        return $this->renameFile(
190
            $thumbnail->getPath(),
191
            $destination,
192
            $this->getFileSystem($currentMedia)
193
        );
194
    }
195
196
    private function removeOldMediaData(MediaEntity $media, Context $context): void
197
    {
198
        if (!$media->hasFile()) {
199
            return;
200
        }
201
202
        try {
203
            $this->getFileSystem($media)->delete($media->getPath());
204
        } catch (UnableToDeleteFile) {
205
            // nth
206
        }
207
208
        $this->thumbnailService->deleteThumbnails($media, $context);
209
    }
210
211
    private function saveFileToMediaDir(MediaFile $mediaFile, MediaEntity $media): void
212
    {
213
        $stream = fopen($mediaFile->getFileName(), 'rb');
214
        if (!\is_resource($stream)) {
215
            throw MediaException::cannotOpenSourceStreamToRead($mediaFile->getFileName());
216
        }
217
218
        $path = $media->getPath();
219
220
        try {
221
            if (\is_resource($stream)) {
222
                $this->getFileSystem($media)->writeStream($path, $stream);
223
            }
224
        } finally {
225
            // The Google Cloud Storage filesystem closes the stream even though it should not. To prevent a fatal
226
            // error, we therefore need to check whether the stream has been closed yet.
227
            if (\is_resource($stream)) {
228
                fclose($stream);
229
            }
230
        }
231
    }
232
233
    private function getFileSystem(MediaEntity $media): FilesystemOperator
234
    {
235
        if ($media->isPrivate()) {
236
            return $this->filesystemPrivate;
237
        }
238
239
        return $this->filesystemPublic;
240
    }
241
242
    /**
243
     * @param array<string, mixed>|null $metadata
244
     */
245
    private function updateMediaEntity(
246
        MediaFile $mediaFile,
247
        string $destination,
248
        MediaEntity $media,
249
        ?array $metadata,
250
        MediaType $mediaType,
251
        Context $context
252
    ): MediaEntity {
253
        $data = [
254
            'id' => $media->getId(),
255
            'userId' => $context->getSource() instanceof AdminApiSource ? $context->getSource()->getUserId() : null,
256
            'mimeType' => $mediaFile->getMimeType(),
257
            'fileExtension' => $mediaFile->getFileExtension(),
258
            'fileSize' => $mediaFile->getFileSize(),
259
            'fileName' => $destination,
260
            'metaData' => $metadata,
261
            'mediaTypeRaw' => serialize($mediaType),
262
        ];
263
264
        if ($media->getUploadedAt() === null) {
265
            $data['uploadedAt'] = new \DateTime();
266
        }
267
268
        $context->scope(Context::SYSTEM_SCOPE, function (Context $context) use ($data): void {
269
            $this->mediaRepository->update([$data], $context);
270
        });
271
272
        $criteria = new Criteria([$media->getId()]);
273
        $criteria->addAssociation('mediaFolder');
274
275
        $this->eventDispatcher->dispatch(new UpdateMediaPathEvent([$media->getId()]));
276
277
        /** @var MediaEntity $media */
278
        $media = $this->mediaRepository->search($criteria, $context)->get($media->getId());
279
280
        return $media;
281
    }
282
283
    /**
284
     * @return array<string, string>
285
     */
286
    private function renameFile(string $source, string $destination, FilesystemOperator $filesystem): array
287
    {
288
        $filesystem->move($source, $destination);
289
290
        return [$source => $destination];
291
    }
292
293
    /**
294
     * @param array<string, string> $renamedFiles
295
     */
296
    private function rollbackRenameAction(MediaEntity $oldMedia, array $renamedFiles): void
297
    {
298
        foreach ($renamedFiles as $oldFileName => $newFileName) {
299
            $this->getFileSystem($oldMedia)->move($newFileName, $oldFileName);
300
        }
301
302
        throw MediaException::couldNotRenameFile($oldMedia->getId(), (string) $oldMedia->getFileName());
303
    }
304
305
    /**
306
     * @throws MediaException
307
     */
308
    private function findMediaById(string $mediaId, Context $context): MediaEntity
309
    {
310
        $criteria = new Criteria([$mediaId]);
311
        $criteria->addAssociation('mediaFolder');
312
        /** @var MediaEntity|null $currentMedia */
313
        $currentMedia = $this->mediaRepository
314
            ->search($criteria, $context)
315
            ->get($mediaId);
316
317
        if ($currentMedia === null) {
318
            throw MediaException::mediaNotFound($mediaId);
319
        }
320
321
        return $currentMedia;
322
    }
323
324
    /**
325
     * @throws MediaException
326
     */
327
    private function validateFileName(string $destination): string
328
    {
329
        $destination = rtrim($destination);
330
        $this->fileNameValidator->validateFileName($destination);
331
332
        return $destination;
333
    }
334
335
    /**
336
     * @throws MediaException
337
     */
338
    private function validateFileExtension(MediaFile $mediaFile, string $mediaId, bool $isPrivate = false): void
339
    {
340
        $event = new MediaFileExtensionWhitelistEvent($isPrivate ? $this->privateAllowedExtensions : $this->allowedExtensions);
341
        $this->eventDispatcher->dispatch($event);
342
343
        $fileExtension = mb_strtolower($mediaFile->getFileExtension());
344
345
        foreach ($event->getWhitelist() as $extension) {
346
            if ($fileExtension === mb_strtolower((string) $extension)) {
347
                return;
348
            }
349
        }
350
351
        throw MediaException::fileExtensionNotSupported($mediaId, $fileExtension);
352
    }
353
354
    /**
355
     * @throws MediaException
356
     */
357
    private function ensureFileNameIsUnique(
358
        MediaEntity $currentMedia,
359
        string $destination,
360
        string $fileExtension,
361
        Context $context
362
    ): void {
363
        $mediaWithRelatedFileName = $this->searchRelatedMediaByFileName(
364
            $currentMedia,
365
            $destination,
366
            $fileExtension,
367
            $context
368
        );
369
370
        foreach ($mediaWithRelatedFileName as $media) {
371
            if (
372
                !$media->hasFile()
373
                || $destination !== $media->getFileName()
374
                || $media->isPrivate() !== $currentMedia->isPrivate()
375
            ) {
376
                continue;
377
            }
378
379
            throw MediaException::duplicatedMediaFileName($destination, $fileExtension);
380
        }
381
    }
382
383
    private function searchRelatedMediaByFileName(
384
        MediaEntity $media,
385
        string $destination,
386
        string $fileExtension,
387
        Context $context
388
    ): MediaCollection {
389
        $criteria = new Criteria();
390
        $criteria->addFilter(new MultiFilter(
391
            MultiFilter::CONNECTION_AND,
392
            [
393
                new EqualsFilter('fileName', $destination),
394
                new EqualsFilter('fileExtension', $fileExtension),
395
                new NotFilter(
396
                    NotFilter::CONNECTION_AND,
397
                    [new EqualsFilter('id', $media->getId())]
398
                ),
399
            ]
400
        ));
401
402
        /** @var MediaCollection $mediaCollection */
403
        $mediaCollection = $this->mediaRepository->search($criteria, $context)->getEntities();
404
405
        return $mediaCollection;
406
    }
407
408
    /**
409
     * @return array<string, string>
410
     */
411
    private function getNewThumbnailPaths(MediaEntity $media, string $destination): array
412
    {
413
        if (!$media->getThumbnails()) {
414
            return [];
415
        }
416
417
        $locations = $this->locationBuilder->thumbnails($media->getThumbnails()->getIds());
418
419
        foreach ($locations as $location) {
420
            $location->media->fileName = $destination;
421
        }
422
423
        return $this->mediaPathStrategy->generate($locations);
424
    }
425
426
    private function getNewMediaPath(MediaEntity $currentMedia, string $destination): string
427
    {
428
        $locations = $this->locationBuilder->media([$currentMedia->getId()]);
429
        $location = $locations[$currentMedia->getId()];
430
        $location->fileName = $destination;
431
432
        $paths = $this->mediaPathStrategy->generate($locations);
433
434
        return $paths[$currentMedia->getId()];
435
    }
436
}
437