Completed
Push — master ( 89c3e8...c161b3 )
by Craig
05:56 queued 40s
created

BundleSyncHelper::syncLostExtensions()   B

Complexity

Conditions 9
Paths 6

Size

Total Lines 29
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 9
eloc 16
c 0
b 0
f 0
nc 6
nop 2
dl 0
loc 29
rs 8.0555
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the Zikula package.
7
 *
8
 * Copyright Zikula Foundation - https://ziku.la/
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13
14
namespace Zikula\ExtensionsModule\Helper;
15
16
use Composer\Semver\Semver;
17
use RuntimeException;
18
use Symfony\Component\ErrorHandler\Error\FatalError;
19
use Symfony\Component\Finder\Finder;
20
use Symfony\Component\HttpFoundation\Session\SessionInterface;
21
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
22
use Symfony\Contracts\Translation\TranslatorInterface;
23
use Zikula\Bundle\CoreBundle\AbstractBundle;
24
use Zikula\Bundle\CoreBundle\Composer\MetaData;
25
use Zikula\Bundle\CoreBundle\Event\GenericEvent;
26
use Zikula\Bundle\CoreBundle\Helper\BundlesSchemaHelper;
27
use Zikula\Bundle\CoreBundle\HttpKernel\ZikulaHttpKernelInterface;
28
use Zikula\Bundle\CoreBundle\HttpKernel\ZikulaKernel;
29
use Zikula\ExtensionsModule\Constant;
30
use Zikula\ExtensionsModule\Entity\ExtensionEntity;
31
use Zikula\ExtensionsModule\Entity\Repository\ExtensionDependencyRepository;
32
use Zikula\ExtensionsModule\Entity\RepositoryInterface\ExtensionRepositoryInterface;
33
use Zikula\ExtensionsModule\Entity\RepositoryInterface\ExtensionVarRepositoryInterface;
34
use Zikula\ExtensionsModule\ExtensionEvents;
35
36
/**
37
 * Helper functions for the extensions bundle
38
 */
39
class BundleSyncHelper
40
{
41
    /**
42
     * @var ZikulaHttpKernelInterface
43
     */
44
    private $kernel;
45
46
    /**
47
     * @var ExtensionRepositoryInterface
48
     */
49
    private $extensionRepository;
50
51
    /**
52
     * @var ExtensionVarRepositoryInterface
53
     */
54
    private $extensionVarRepository;
55
56
    /**
57
     * @var ExtensionDependencyRepository
58
     */
59
    private $extensionDependencyRepository;
60
61
    /**
62
     * @var TranslatorInterface
63
     */
64
    private $translator;
65
66
    /**
67
     * @var EventDispatcherInterface
68
     */
69
    private $dispatcher;
70
71
    /**
72
     * @var ExtensionStateHelper
73
     */
74
    private $extensionStateHelper;
75
76
    /**
77
     * @var BundlesSchemaHelper
78
     */
79
    private $bundlesSchemaHelper;
80
81
    /**
82
     * @var ComposerValidationHelper
83
     */
84
    private $composerValidationHelper;
85
86
    /**
87
     * @var SessionInterface
88
     */
89
    protected $session;
90
91
    public function __construct(
92
        ZikulaHttpKernelInterface $kernel,
93
        ExtensionRepositoryInterface $extensionRepository,
94
        ExtensionVarRepositoryInterface $extensionVarRepository,
95
        ExtensionDependencyRepository $extensionDependencyRepository,
96
        TranslatorInterface $translator,
97
        EventDispatcherInterface $dispatcher,
98
        ExtensionStateHelper $extensionStateHelper,
99
        BundlesSchemaHelper $bundlesSchemaHelper,
100
        ComposerValidationHelper $composerValidationHelper,
101
        SessionInterface $session
102
    ) {
103
        $this->kernel = $kernel;
104
        $this->extensionRepository = $extensionRepository;
105
        $this->extensionVarRepository = $extensionVarRepository;
106
        $this->extensionDependencyRepository = $extensionDependencyRepository;
107
        $this->translator = $translator;
108
        $this->dispatcher = $dispatcher;
109
        $this->extensionStateHelper = $extensionStateHelper;
110
        $this->bundlesSchemaHelper = $bundlesSchemaHelper;
111
        $this->composerValidationHelper = $composerValidationHelper;
112
        $this->session = $session;
113
    }
114
115
    /**
116
     * Scan the extensions directory for bundles and returns an array with all (potential) bundles found.
117
     */
118
    public function scanForBundles($includeCore = false): array
119
    {
120
        // sync the extensions directory and the bundles table
121
        $this->bundlesSchemaHelper->load();
122
        $scanner = $this->bundlesSchemaHelper->getScanner();
123
        foreach ($scanner->getInvalid() as $invalidName) {
124
            $this->session->getFlashBag()->add(
125
                'warning',
126
                $this->translator->trans(
127
                    'WARNING: %extension% has an invalid composer.json file which could not be decoded.',
128
                    ['%extension%' => $invalidName]
129
                )
130
            );
131
        }
132
        $extensions = $scanner->getExtensionsMetaData();
133
134
        $bundles = [];
135
        $srcDir = $this->kernel->getProjectDir() . '/src/';
136
        /** @var MetaData $bundleMetaData */
137
        foreach ($extensions as $name => $bundleMetaData) {
138
            foreach ($bundleMetaData->getPsr4() as $ns => $path) {
139
                $this->kernel->getAutoloader()->addPsr4($ns, $srcDir . $path);
140
            }
141
142
            $bundleClass = $bundleMetaData->getClass();
143
144
            /** @var $bundle \Zikula\Bundle\CoreBundle\AbstractBundle */
145
            $bundle = new $bundleClass();
146
            $bundleMetaData->setTranslator($this->translator);
147
            $bundleVersionArray = $bundleMetaData->getFilteredVersionInfoArray();
148
149
            $finder = new Finder();
150
            $finder->files()->in($bundle->getPath())->depth(0)->name('composer.json');
151
            foreach ($finder as $splFileInfo) {
152
                // there will only be one loop here
153
                $this->composerValidationHelper->check($splFileInfo);
154
                if ($this->composerValidationHelper->isValid()) {
155
                    $bundles[$bundle->getName()] = $bundleVersionArray;
156
                    $bundles[$bundle->getName()]['oldnames'] = $bundleVersionArray['oldnames'] ?? '';
157
                } else {
158
                    $this->session->getFlashBag()->add(
159
                        'error',
160
                        $this->translator->trans(
161
                            'Cannot load %extension% because the composer file is invalid.',
162
                            ['%extension%' => $bundle->getName()]
163
                        )
164
                    );
165
                    foreach ($this->composerValidationHelper->getErrors() as $error) {
166
                        $this->session->getFlashBag()->add('error', $error);
167
                    }
168
                }
169
            }
170
        }
171
172
        if ($includeCore) {
173
            $this->appendCoreExtensionsMetaData($bundles);
174
        }
175
        $this->validate($bundles);
176
177
        return $bundles;
178
    }
179
180
    private function appendCoreExtensionsMetaData(array &$extensions): void
181
    {
182
        foreach (ZikulaKernel::$coreExtension as $systemModule => $bundleClass) {
183
            $bundle = $this->kernel->getBundle($systemModule);
184
            if ($bundle instanceof AbstractBundle) {
185
                $extensions[$systemModule] = $bundle->getMetaData()->getFilteredVersionInfoArray();
186
            }
187
        }
188
    }
189
190
    /**
191
     * Validate the extensions and ensure there are no duplicate names, display names or urls.
192
     *
193
     * @throws FatalError
194
     */
195
    private function validate(array $extensions = []): void
196
    {
197
        $fieldNames = ['name', 'displayname', 'url'];
198
        $moduleValues = [
199
            'name' => [],
200
            'displayname' => [],
201
            'url' => []
202
        ];
203
204
        // check for duplicate name, display name or url
205
        foreach ($extensions as $dir => $modInfo) {
206
            foreach ($fieldNames as $fieldName) {
207
                $key = mb_strtolower($modInfo[$fieldName]);
208
                if (!empty($moduleValues[$fieldName][$key]) && !empty($modInfo[$fieldName])) {
209
                    $message = $this->translator->trans('Fatal error: Two extensions share the same %field%. [%ext1%] and [%ext2%]', [
210
                        '%field%' => $fieldName,
211
                        '%ext1%' => $modInfo['name'],
212
                        '%ext2%' => $moduleValues[$fieldName][$key]
213
                    ]);
214
                    throw new FatalError($message, 500, error_get_last());
215
                }
216
                $moduleValues[$fieldName][$key] = $dir;
217
            }
218
        }
219
    }
220
221
    /**
222
     * Sync extensions in the filesystem and the extensions table.
223
     *
224
     * @return array $upgradedExtensions[<name>] = <version>
225
     */
226
    public function syncExtensions(array $extensionsFromFile, bool $forceDefaults = false): array
227
    {
228
        // Get all extensions in DB, indexed by name
229
        $extensionsFromDB = $this->extensionRepository->getIndexedArrayCollection('name');
230
231
        // see if any extensions have changed since last regeneration
232
        $this->syncUpdatedExtensions($extensionsFromFile, $extensionsFromDB, $forceDefaults);
233
234
        // See if any extensions have been lost since last sync
235
        $this->syncLostExtensions($extensionsFromFile, $extensionsFromDB);
236
237
        // See any extensions have been gained since last sync,
238
        // or if any current extensions have been upgraded
239
        $upgradedExtensions = $this->syncAddedExtensions($extensionsFromFile, $extensionsFromDB);
240
241
        // Clear and reload the dependencies table with all current dependencies
242
        $this->extensionDependencyRepository->reloadExtensionDependencies($extensionsFromFile);
243
244
        return $upgradedExtensions;
245
    }
246
247
    /**
248
     * Sync extensions that are already in the Database.
249
     *  - update from old names
250
     *  - update compatibility
251
     *  - update user settings (or reset to defaults)
252
     *  - ensure current core compatibility
253
     */
254
    private function syncUpdatedExtensions(
255
        array $extensionsFromFile,
256
        array &$extensionsFromDB,
257
        bool $forceDefaults = false
258
    ): void {
259
        foreach ($extensionsFromFile as $name => $extensionFromFile) {
260
            foreach ($extensionsFromDB as $dbname => $extensionFromDB) {
261
                if (isset($extensionFromDB['name']) && in_array($extensionFromDB['name'], (array)$extensionFromFile['oldnames'], true)) {
262
                    // migrate its modvars
263
                    $this->extensionVarRepository->updateName($dbname, $name);
264
                    // rename the extension register
265
                    $this->extensionRepository->updateName($dbname, $name);
266
                    // replace the old extension with the new one in the $extensionsFromDB array
267
                    $extensionsFromDB[$name] = $extensionFromDB;
268
                    unset($extensionsFromDB[$dbname]);
269
                }
270
            }
271
272
            // If extension was previously determined to be incompatible with the core. return to original state
273
            if (isset($extensionsFromDB[$name]) && $extensionsFromDB[$name]['state'] > 10) {
274
                $extensionsFromDB[$name]['state'] -= Constant::INCOMPATIBLE_CORE_SHIFT;
275
                $this->extensionStateHelper->updateState($extensionsFromDB[$name]['id'], $extensionsFromDB[$name]['state']);
276
            }
277
278
            // update the DB information for this extension to reflect user settings (e.g. url)
279
            if (isset($extensionsFromDB[$name]['id'])) {
280
                $extensionFromFile['id'] = $extensionsFromDB[$name]['id'];
281
                if (Constant::STATE_UNINITIALISED !== $extensionsFromDB[$name]['state'] && Constant::STATE_INVALID !== $extensionsFromDB[$name]['state']) {
282
                    unset($extensionFromFile['version']);
283
                }
284
                if (!$forceDefaults) {
285
                    unset($extensionFromFile['displayname'], $extensionFromFile['description'], $extensionFromFile['url']);
286
                }
287
288
                unset($extensionFromFile['oldnames'], $extensionFromFile['dependencies']);
289
290
                /** @var ExtensionEntity $extension */
291
                $extension = $this->extensionRepository->find($extensionFromFile['id']);
292
                $extension->merge($extensionFromFile);
293
                $this->extensionRepository->persistAndFlush($extension);
294
            }
295
296
            // check extension core requirement is compatible with current core
297
            $coreCompatibility = $extensionFromFile['coreCompatibility'];
298
            if (isset($extensionsFromDB[$name])) {
299
                if (!Semver::satisfies(ZikulaKernel::VERSION, $coreCompatibility)) {
300
                    // extension is incompatible with current core
301
                    $extensionsFromDB[$name]['state'] += Constant::INCOMPATIBLE_CORE_SHIFT;
302
                    $this->extensionStateHelper->updateState($extensionsFromDB[$name]['id'], $extensionsFromDB[$name]['state']);
303
                }
304
                if (isset($extensionsFromDB[$name]['state'])) {
305
                    $extensionFromFile['state'] = $extensionsFromDB[$name]['state'];
306
                }
307
            }
308
        }
309
    }
310
311
    /**
312
     * Remove extensions from the DB that have been removed from the filesystem.
313
     */
314
    private function syncLostExtensions(array $extensionsFromFile, array &$extensionsFromDB): void
315
    {
316
        foreach ($extensionsFromDB as $extensionName => $unusedVariable) {
317
            if ($this->kernel::isCoreExtension($extensionName) || array_key_exists($extensionName, $extensionsFromFile)) {
318
                continue;
319
            }
320
321
            $lostExtension = $this->extensionRepository->get($extensionName); // must obtain Entity because value from $extensionsFromDB is only an array
322
            if (!$lostExtension) {
323
                throw new RuntimeException($this->translator->trans('Error! Could not load data for %extension%.', ['%extension%' => $extensionName]));
324
            }
325
            $lostExtensionState = $lostExtension->getState();
326
            if ((Constant::STATE_INVALID === $lostExtensionState)
327
                || ($lostExtensionState === Constant::STATE_INVALID + Constant::INCOMPATIBLE_CORE_SHIFT)) {
328
                // extension was invalid and subsequently removed from file system,
329
                // or extension was incompatible with core and subsequently removed, delete it
330
                $this->extensionRepository->removeAndFlush($lostExtension);
331
            } elseif ((Constant::STATE_UNINITIALISED === $lostExtensionState)
332
                || ($lostExtensionState === Constant::STATE_UNINITIALISED + Constant::INCOMPATIBLE_CORE_SHIFT)) {
333
                // extension was uninitialised and subsequently removed from file system, delete it
334
                $this->extensionRepository->removeAndFlush($lostExtension);
335
            } else {
336
                // Set state of extension to 'missing'
337
                // This state cannot be reached in with an ACTIVE bundle. - ACTIVE bundles are part of the pre-compiled Kernel.
338
                // extensions that are inactive can be marked as missing.
339
                $this->extensionStateHelper->updateState($lostExtension->getId(), Constant::STATE_MISSING);
340
            }
341
342
            unset($extensionsFromDB[$extensionName]);
343
        }
344
    }
345
346
    /**
347
     * Add extensions to the DB that have been added to the filesystem.
348
     *  - add uninitialized extensions
349
     *  - update missing or invalid extensions
350
     *
351
     * @return array $upgradedExtensions[<name>] => <version>
352
     */
353
    private function syncAddedExtensions(array $extensionsFromFile, array $extensionsFromDB): array
354
    {
355
        $upgradedExtensions = [];
356
357
        foreach ($extensionsFromFile as $name => $extensionFromFile) {
358
            if (empty($extensionsFromDB[$name])) {
359
                $extensionFromFile['state'] = Constant::STATE_UNINITIALISED;
360
                if (!$extensionFromFile['version']) {
361
                    // set state to invalid if we can't determine a version
362
                    $extensionFromFile['state'] = Constant::STATE_INVALID;
363
                } else {
364
                    $coreCompatibility = $extensionFromFile['coreCompatibility'];
365
                    // shift state if extension is incompatible with core version
366
                    $extensionFromFile['state'] = Semver::satisfies(ZikulaKernel::VERSION, $coreCompatibility)
367
                        ? $extensionFromFile['state']
368
                        : $extensionFromFile['state'] + Constant::INCOMPATIBLE_CORE_SHIFT;
369
                }
370
371
                // unset vars that don't matter
372
                unset($extensionFromFile['oldnames'], $extensionFromFile['dependencies']);
373
374
                // insert new extension to db
375
                $newExtension = new ExtensionEntity();
376
                $newExtension->merge($extensionFromFile);
377
                $vetoEvent = new GenericEvent($newExtension);
378
                $this->dispatcher->dispatch($vetoEvent, ExtensionEvents::INSERT_VETO);
379
                if (!$vetoEvent->isPropagationStopped()) {
380
                    $this->extensionRepository->persistAndFlush($newExtension);
381
                }
382
            } else {
383
                // extension is in the db already
384
                if ((Constant::STATE_MISSING === $extensionsFromDB[$name]['state'])
385
                    || ($extensionsFromDB[$name]['state'] === Constant::STATE_MISSING + Constant::INCOMPATIBLE_CORE_SHIFT)) {
386
                    // extension was lost, now it is here again
387
                    $this->extensionStateHelper->updateState($extensionsFromDB[$name]['id'], Constant::STATE_INACTIVE);
388
                } elseif (((Constant::STATE_INVALID === $extensionsFromDB[$name]['state'])
389
                        || ($extensionsFromDB[$name]['state'] === Constant::STATE_INVALID + Constant::INCOMPATIBLE_CORE_SHIFT))
390
                    && $extensionFromFile['version']) {
391
                    $coreCompatibility = $extensionFromFile['coreCompatibility'];
392
                    if (Semver::satisfies(ZikulaKernel::VERSION, $coreCompatibility)) {
393
                        // extension was invalid, now it is valid
394
                        $this->extensionStateHelper->updateState($extensionsFromDB[$name]['id'], Constant::STATE_UNINITIALISED);
395
                    }
396
                }
397
398
                if ($extensionsFromDB[$name]['version'] !== $extensionFromFile['version']) {
399
                    if (Constant::STATE_UNINITIALISED !== $extensionsFromDB[$name]['state'] &&
400
                        Constant::STATE_INVALID !== $extensionsFromDB[$name]['state']) {
401
                        $this->extensionStateHelper->updateState($extensionsFromDB[$name]['id'], Constant::STATE_UPGRADED);
402
                        $upgradedExtensions[$name] = $extensionFromFile['version'];
403
                    }
404
                }
405
            }
406
        }
407
408
        return $upgradedExtensions;
409
    }
410
}
411