Completed
Push — master ( 801e4f...e2a4d0 )
by Craig
07:35
created

BundleSyncHelper::isCoreCompatible()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 1
dl 0
loc 7
rs 9.4285
c 0
b 0
f 0
1
<?php
2
3
/*
4
 * This file is part of the Zikula package.
5
 *
6
 * Copyright Zikula Foundation - http://zikula.org/
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Zikula\ExtensionsModule\Helper;
13
14
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
15
use Symfony\Component\Finder\Finder;
16
use Symfony\Component\HttpFoundation\Session\SessionInterface;
17
use Symfony\Component\HttpKernel\KernelInterface;
18
use vierbergenlars\SemVer\expression;
19
use vierbergenlars\SemVer\version;
20
use Zikula\Bundle\CoreBundle\Bundle\Helper\BootstrapHelper;
21
use Zikula\Bundle\CoreBundle\Bundle\MetaData;
22
use Zikula\Bundle\CoreBundle\Bundle\Scanner;
23
use Zikula\Common\Translator\TranslatorInterface;
24
use Zikula\Core\Event\GenericEvent;
25
use Zikula\Core\Exception\FatalErrorException;
26
use Zikula\ExtensionsModule\Api\ExtensionApi;
27
use Zikula\ExtensionsModule\Entity\ExtensionEntity;
28
use Zikula\ExtensionsModule\Entity\Repository\ExtensionDependencyRepository;
29
use Zikula\ExtensionsModule\Entity\Repository\ExtensionRepository;
30
use Zikula\ExtensionsModule\Entity\Repository\ExtensionVarRepository;
31
use Zikula\ExtensionsModule\ExtensionEvents;
32
33
/**
34
 * Helper functions for the extensions bundle
35
 */
36
class BundleSyncHelper
37
{
38
    /**
39
     * @var KernelInterface
40
     */
41
    private $kernel;
42
43
    /**
44
     * @var ExtensionRepository
45
     */
46
    private $extensionRepository;
47
48
    /**
49
     * @var ExtensionVarRepository
50
     */
51
    private $extensionVarRepository;
52
53
    /**
54
     * @var ExtensionDependencyRepository
55
     */
56
    private $extensionDependencyRepository;
57
58
    /**
59
     * @var TranslatorInterface
60
     */
61
    private $translator;
62
63
    /**
64
     * @var EventDispatcherInterface
65
     */
66
    private $dispatcher;
67
68
    /**
69
     * @var ExtensionStateHelper
70
     */
71
    private $extensionStateHelper;
72
73
    /**
74
     * @var BootstrapHelper
75
     */
76
    private $bootstrapHelper;
77
78
    /**
79
     * @var ComposerValidationHelper
80
     */
81
    private $composerValidationHelper;
82
83
    /**
84
     * @var SessionInterface
85
     */
86
    protected $session;
87
88
    /**
89
     * BundleSyncHelper constructor.
90
     *
91
     * @param KernelInterface $kernel
92
     * @param ExtensionRepository $extensionRepository
93
     * @param ExtensionVarRepository $extensionVarRepository
94
     * @param ExtensionDependencyRepository $extensionDependencyRepository
95
     * @param TranslatorInterface $translator
96
     * @param EventDispatcherInterface $dispatcher
97
     * @param ExtensionStateHelper $extensionStateHelper
98
     * @param ComposerValidationHelper $composerValidationHelper
99
     * @param SessionInterface $session
100
     */
101
    public function __construct(
102
        KernelInterface $kernel,
103
        ExtensionRepository $extensionRepository,
104
        ExtensionVarRepository $extensionVarRepository,
105
        ExtensionDependencyRepository $extensionDependencyRepository,
106
        TranslatorInterface $translator,
107
        EventDispatcherInterface $dispatcher,
108
        ExtensionStateHelper $extensionStateHelper,
109
        BootstrapHelper $bootstrapHelper,
110
        ComposerValidationHelper $composerValidationHelper,
111
        SessionInterface $session
112
    ) {
113
        $this->kernel = $kernel;
114
        $this->extensionRepository = $extensionRepository;
115
        $this->extensionVarRepository = $extensionVarRepository;
116
        $this->extensionDependencyRepository = $extensionDependencyRepository;
117
        $this->translator = $translator;
118
        $this->dispatcher = $dispatcher;
119
        $this->extensionStateHelper = $extensionStateHelper;
120
        $this->bootstrapHelper = $bootstrapHelper;
121
        $this->composerValidationHelper = $composerValidationHelper;
122
        $this->session = $session;
123
    }
124
125
    /**
126
     * Scan the file system for bundles.
127
     *
128
     * This function scans the file system for bundles and returns an array with all (potential) bundles found.
129
     *
130
     * @param array $directories
131
     * @return array Thrown if the user doesn't have admin permissions over the bundle
132
     * @throws \Exception
133
     */
134
    public function scanForBundles(array $directories = [])
135
    {
136
        $directories = empty($directories) ? ['system', 'modules'] : $directories;
137
138
        // sync the filesystem and the bundles table
139
        $this->bootstrapHelper->load();
140
141
        // Get all bundles on filesystem
142
        $bundles = [];
143
144
        $scanner = new Scanner();
145
        $scanner->scan($directories, 5);
146
        $newModules = $scanner->getModulesMetaData();
147
148
        // scan for all bundle-type bundles (psr-4) in either /system or /bundles
149
        /** @var MetaData $bundleMetaData */
150
        foreach ($newModules as $name => $bundleMetaData) {
151
            foreach ($bundleMetaData->getPsr4() as $ns => $path) {
152
                $this->kernel->getAutoloader()->addPsr4($ns, $path);
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Symfony\Component\HttpKernel\KernelInterface as the method getAutoloader() does only exist in the following implementations of said interface: ZikulaKernel, Zikula\Bundle\CoreBundle\HttpKernel\ZikulaKernel.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
153
            }
154
155
            $bundleClass = $bundleMetaData->getClass();
156
157
            /** @var $bundle \Zikula\Core\AbstractModule */
158
            $bundle = new $bundleClass();
159
            $bundleMetaData->setTranslator($this->translator);
160
            $bundleMetaData->setDirectoryFromBundle($bundle);
161
            $bundleVersionArray = $bundleMetaData->getFilteredVersionInfoArray();
162
            $bundleVersionArray['capabilities'] = serialize($bundleVersionArray['capabilities']);
163
            $bundleVersionArray['securityschema'] = serialize($bundleVersionArray['securityschema']);
164
            $bundleVersionArray['dependencies'] = serialize($bundleVersionArray['dependencies']);
165
166
            $finder = new Finder();
167
            $finder->files()->in($bundle->getPath())->depth(0)->name('composer.json');
168
            foreach ($finder as $splFileInfo) {
169
                // there will only be one loop here
170
                $this->composerValidationHelper->check($splFileInfo);
171
                if ($this->composerValidationHelper->isValid()) {
172
                    $bundles[$bundle->getName()] = $bundleVersionArray;
173
                    $bundles[$bundle->getName()]['oldnames'] = isset($bundleVersionArray['oldnames']) ? $bundleVersionArray['oldnames'] : '';
174 View Code Duplication
                } else {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
175
                    $this->session->getFlashBag()->add('error', $this->translator->__f('Cannot load %extension because the composer file is invalid.', ['%extension' => $bundle->getName()]));
176
                    foreach ($this->composerValidationHelper->getErrors() as $error) {
177
                        $this->session->getFlashBag()->add('error', $error);
178
                    }
179
                }
180
            }
181
        }
182
183
        $this->validate($bundles);
184
185
        return $bundles;
186
    }
187
188
    /**
189
     * Validate the extensions and ensure there are no duplicate names, displaynames or urls.
190
     *
191
     * @param array $extensions
192
     * @throws FatalErrorException
193
     */
194
    private function validate(array $extensions)
195
    {
196
        $modulenames = [];
197
        $displaynames = [];
198
        $urls = [];
199
200
        // check for duplicate names, display names or urls
201
        foreach ($extensions as $dir => $modInfo) {
202 View Code Duplication
            if (isset($modulenames[strtolower($modInfo['name'])])) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
203
                throw new FatalErrorException($this->translator->__f('Fatal Error: Two extensions share the same name. [%ext1%] and [%ext2%]', [
204
                    '%ext1%' => $modInfo['name'],
205
                    '%ext2%' => $modulenames[strtolower($modInfo['name'])]
206
                ]));
207
            }
208
209 View Code Duplication
            if (isset($displaynames[strtolower($modInfo['displayname'])])) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
210
                throw new FatalErrorException($this->translator->__f('Fatal Error: Two extensions share the same displayname. [%ext1%] and [%ext2%]', [
211
                    '%ext1%' => $modInfo['name'],
212
                    '%ext2%' => $modulenames[strtolower($modInfo['name'])]
213
                ]));
214
            }
215
216 View Code Duplication
            if (isset($urls[strtolower($modInfo['url'])])) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
217
                throw new FatalErrorException($this->translator->__f('Fatal Error: Two extensions share the same url. [%ext1%] and [%ext2%]', [
218
                    '%ext1%' => $modInfo['name'],
219
                    '%ext2%' => $modulenames[strtolower($modInfo['name'])]
220
                ]));
221
            }
222
223
            $modulenames[strtolower($modInfo['name'])] = $dir;
224
            $displaynames[strtolower($modInfo['displayname'])] = $dir;
225
            $urls[strtolower($modInfo['url'])] = $dir;
226
        }
227
    }
228
229
    /**
230
     * Sync extensions in the filesystem and the database.
231
     *
232
     * @param array $extensionsFromFile
233
     * @param bool $forceDefaults
234
     * @return array $upgradedExtensions[<name>] = <version>
235
     */
236
    public function syncExtensions(array $extensionsFromFile, $forceDefaults = false)
237
    {
238
        // Get all extensions in DB, indexed by name
239
        $extensionsFromDB = $this->extensionRepository->getIndexedArrayCollection('name');
240
241
        // see if any extensions have changed since last regeneration
242
        $this->syncUpdatedExtensions($extensionsFromFile, $extensionsFromDB, $forceDefaults);
243
244
        // See if any extensions have been lost since last sync
245
        $this->syncLostExtensions($extensionsFromFile, $extensionsFromDB);
246
247
        // See any extensions have been gained since last sync,
248
        // or if any current extensions have been upgraded
249
        $upgradedExtensions = $this->syncAddedExtensions($extensionsFromFile, $extensionsFromDB);
250
251
        // Clear and reload the dependencies table with all current dependencies
252
        $this->extensionDependencyRepository->reloadExtensionDependencies($extensionsFromFile);
253
254
        return $upgradedExtensions;
255
    }
256
257
    /**
258
     * Sync extensions that are already in the Database.
259
     *  - update from old names
260
     *  - update compatibility
261
     *  - update user settings (or reset to defaults)
262
     *  - ensure current core compatibility
263
     *
264
     * @param array $extensionsFromFile
265
     * @param array $extensionsFromDB
266
     * @param bool $forceDefaults
267
     */
268
    private function syncUpdatedExtensions(array $extensionsFromFile, array &$extensionsFromDB, $forceDefaults = false)
269
    {
270
        foreach ($extensionsFromFile as $name => $extensionFromFile) {
271
            foreach ($extensionsFromDB as $dbname => $extensionFromDB) {
272
                if (isset($extensionFromDB['name']) && in_array($extensionFromDB['name'], (array)$extensionFromFile['oldnames'])) {
273
                    // migrate its modvars
274
                    $this->extensionVarRepository->updateName($dbname, $name);
275
                    // rename the module register
276
                    $this->extensionRepository->updateName($dbname, $name);
277
                    // replace the old module with the new one in the $extensionsFromDB array
278
                    $extensionsFromDB[$name] = $extensionFromDB;
279
                    unset($extensionsFromDB[$dbname]);
280
                }
281
            }
282
283
            // If extension was previously determined to be incompatible with the core. return to original state
284
            if (isset($extensionsFromDB[$name]) && $extensionsFromDB[$name]['state'] > 10) {
285
                $extensionsFromDB[$name]['state'] = $extensionsFromDB[$name]['state'] - ExtensionApi::INCOMPATIBLE_CORE_SHIFT;
286
                $this->extensionStateHelper->updateState($extensionsFromDB[$name]['id'], $extensionsFromDB[$name]['state']);
287
            }
288
289
            // update the DB information for this extension to reflect user settings (e.g. url)
290
            if (isset($extensionsFromDB[$name]['id'])) {
291
                $extensionFromFile['id'] = $extensionsFromDB[$name]['id'];
292
                if ($extensionsFromDB[$name]['state'] != ExtensionApi::STATE_UNINITIALISED && $extensionsFromDB[$name]['state'] != ExtensionApi::STATE_INVALID) {
293
                    unset($extensionFromFile['version']);
294
                }
295
                if (!$forceDefaults) {
296
                    unset($extensionFromFile['displayname']);
297
                    unset($extensionFromFile['description']);
298
                    unset($extensionFromFile['url']);
299
                }
300
301
                unset($extensionFromFile['oldnames']);
302
                unset($extensionFromFile['dependencies']);
303
                $extensionFromFile['capabilities'] = unserialize($extensionFromFile['capabilities']);
304
                $extensionFromFile['securityschema'] = unserialize($extensionFromFile['securityschema']);
305
                $extension = $this->extensionRepository->find($extensionFromFile['id']);
306
                $extension->merge($extensionFromFile);
307
                $this->extensionRepository->persistAndFlush($extension);
308
            }
309
310
            // check extension core requirement is compatible with current core
311
            $coreCompatibility = isset($extensionFromFile['corecompatibility'])
312
                ? $extensionFromFile['corecompatibility']
313
                : $this->formatCoreCompatibilityString($extensionFromFile['core_min'], $extensionFromFile['core_max']);
314
            $isCompatible = $this->isCoreCompatible($coreCompatibility);
315
            if (isset($extensionsFromDB[$name])) {
316
                if (!$isCompatible) {
317
                    // extension is incompatible with current core
318
                    $extensionsFromDB[$name]['state'] = $extensionsFromDB[$name]['state'] + ExtensionApi::INCOMPATIBLE_CORE_SHIFT;
319
                    $this->extensionStateHelper->updateState($extensionsFromDB[$name]['id'], $extensionsFromDB[$name]['state']);
320
                }
321
                if (isset($extensionsFromDB[$name]['state'])) {
322
                    $extensionFromFile['state'] = $extensionsFromDB[$name]['state'];
323
                }
324
            }
325
        }
326
    }
327
328
    /**
329
     * Remove extensions from the DB that have been removed from the filesystem.
330
     *
331
     * @param array $extensionsFromFile
332
     * @param array $extensionsFromDB
333
     */
334
    private function syncLostExtensions(array $extensionsFromFile, array &$extensionsFromDB)
335
    {
336
        foreach ($extensionsFromDB as $name => $unusedVariable) {
337
            if (array_key_exists($name, $extensionsFromFile)) {
338
                continue;
339
            }
340
341
            $lostModule = $this->extensionRepository->get($name); // must obtain Entity because value from $extensionsFromDB is only an array
342
            if (!$lostModule) {
343
                throw new \RuntimeException($this->translator->__f('Error! Could not load data for module %s.', [$name]));
344
            }
345
            $lostModuleState = $lostModule->getState();
346
            if (($lostModuleState == ExtensionApi::STATE_INVALID)
347
                || ($lostModuleState == ExtensionApi::STATE_INVALID + ExtensionApi::INCOMPATIBLE_CORE_SHIFT)) {
348
                // extension was invalid and subsequently removed from file system,
349
                // or extension was incompatible with core and subsequently removed, delete it
350
                $this->extensionRepository->removeAndFlush($lostModule);
351
            } elseif (($lostModuleState == ExtensionApi::STATE_UNINITIALISED)
352
                || ($lostModuleState == ExtensionApi::STATE_UNINITIALISED + ExtensionApi::INCOMPATIBLE_CORE_SHIFT)) {
353
                // extension was uninitialised and subsequently removed from file system, delete it
354
                $this->extensionRepository->removeAndFlush($lostModule);
355
            } else {
356
                // Set state of module to 'missing'
357
                // This state cannot be reached in with an ACTIVE bundle. - ACTIVE bundles are part of the pre-compiled Kernel.
358
                // extensions that are inactive can be marked as missing.
359
                $this->extensionStateHelper->updateState($lostModule->getId(), ExtensionApi::STATE_MISSING);
360
            }
361
362
            unset($extensionsFromDB[$name]);
363
        }
364
    }
365
366
    /**
367
     * Add extensions to the DB that have been added to the filesystem.
368
     *  - add uninitialized extensions
369
     *  - update missing or invalid extensions
370
     *
371
     * @param array $extensionsFromFile
372
     * @param array $extensionsFromDB
373
     * @return array $upgradedExtensions[<name>] => <version>
374
     */
375
    private function syncAddedExtensions(array $extensionsFromFile, array $extensionsFromDB)
376
    {
377
        $upgradedExtensions = [];
378
379
        foreach ($extensionsFromFile as $name => $extensionFromFile) {
380
            if (empty($extensionsFromDB[$name])) {
381
                $extensionFromFile['state'] = ExtensionApi::STATE_UNINITIALISED;
382
                if (!$extensionFromFile['version']) {
383
                    // set state to invalid if we can't determine a version
384
                    $extensionFromFile['state'] = ExtensionApi::STATE_INVALID;
385
                } else {
386
                    $coreCompatibility = isset($extensionFromFile['corecompatibility'])
387
                        ? $extensionFromFile['corecompatibility']
388
                        : $this->formatCoreCompatibilityString($extensionFromFile['core_min'], $extensionFromFile['core_max']);
389
                    // shift state if module is incompatible with core version
390
                    $extensionFromFile['state'] = $this->isCoreCompatible($coreCompatibility)
391
                        ? $extensionFromFile['state']
392
                        : $extensionFromFile['state'] + ExtensionApi::INCOMPATIBLE_CORE_SHIFT;
393
                }
394
395
                // unset vars that don't matter
396
                unset($extensionFromFile['oldnames']);
397
                unset($extensionFromFile['dependencies']);
398
399
                // unserialize vars
400
                $extensionFromFile['capabilities'] = unserialize($extensionFromFile['capabilities']);
401
                $extensionFromFile['securityschema'] = unserialize($extensionFromFile['securityschema']);
402
403
                // insert new module to db
404
                $newExtension = new ExtensionEntity();
405
                $newExtension->merge($extensionFromFile);
406
                $vetoEvent = new GenericEvent($newExtension);
407
                $this->dispatcher->dispatch(ExtensionEvents::INSERT_VETO, $vetoEvent);
408
                if (!$vetoEvent->isPropagationStopped()) {
409
                    $this->extensionRepository->persistAndFlush($newExtension);
410
                }
411
            } else {
412
                // extension is in the db already
413
                if (($extensionsFromDB[$name]['state'] == ExtensionApi::STATE_MISSING)
414
                    || ($extensionsFromDB[$name]['state'] == ExtensionApi::STATE_MISSING + ExtensionApi::INCOMPATIBLE_CORE_SHIFT)) {
415
                    // extension was lost, now it is here again
416
                    $this->extensionStateHelper->updateState($extensionsFromDB[$name]['id'], ExtensionApi::STATE_INACTIVE);
417
                } elseif ((($extensionsFromDB[$name]['state'] == ExtensionApi::STATE_INVALID)
418
                        || ($extensionsFromDB[$name]['state'] == ExtensionApi::STATE_INVALID + ExtensionApi::INCOMPATIBLE_CORE_SHIFT))
419
                    && $extensionFromFile['version']) {
420
                    $coreCompatibility = isset($extensionFromFile['corecompatibility'])
421
                        ? $extensionFromFile['corecompatibility']
422
                        : $this->formatCoreCompatibilityString($extensionFromFile['core_min'], $extensionFromFile['core_max']);
423
                    $isCompatible = $this->isCoreCompatible($coreCompatibility);
424
                    if ($isCompatible) {
425
                        // extension was invalid, now it is valid
426
                        $this->extensionStateHelper->updateState($extensionsFromDB[$name]['id'], ExtensionApi::STATE_UNINITIALISED);
427
                    }
428
                }
429
430
                if ($extensionsFromDB[$name]['version'] != $extensionFromFile['version']) {
431
                    if ($extensionsFromDB[$name]['state'] != ExtensionApi::STATE_UNINITIALISED &&
432
                        $extensionsFromDB[$name]['state'] != ExtensionApi::STATE_INVALID) {
433
                        $this->extensionStateHelper->updateState($extensionsFromDB[$name]['id'], ExtensionApi::STATE_UPGRADED);
434
                        $upgradedExtensions[$name] = $extensionFromFile['version'];
435
                    }
436
                }
437
            }
438
        }
439
440
        return $upgradedExtensions;
441
    }
442
443
    /**
444
     * Determine if $min and $max values are compatible with Current Core version
445
     *
446
     * @param string $compatibilityString Semver
447
     * @return bool
448
     */
449
    private function isCoreCompatible($compatibilityString)
450
    {
451
        $coreVersion = new version(\ZikulaKernel::VERSION);
452
        $requiredVersionExpression = new expression($compatibilityString);
453
454
        return $requiredVersionExpression->satisfiedBy($coreVersion);
455
    }
456
457
    /**
458
     * Format a compatibility string suitable for semver comparison using vierbergenlars/php-semver
459
     *
460
     * @param null $coreMin
461
     * @param null $coreMax
462
     * @return string
463
     */
464
    private function formatCoreCompatibilityString($coreMin = null, $coreMax = null)
465
    {
466
        $coreMin = !empty($coreMin) ? $coreMin : '1.4.0';
467
        $coreMax = !empty($coreMax) ? $coreMax : '2.9.99';
468
469
        return $coreMin . ' - ' . $coreMax;
470
    }
471
}
472