Completed
Push — master ( 2b88ce...108b89 )
by Craig
06:26
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 Composer\Semver\Semver;
15
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
16
use Symfony\Component\Finder\Finder;
17
use Symfony\Component\HttpFoundation\Session\SessionInterface;
18
use Zikula\Bundle\CoreBundle\Bundle\Helper\BootstrapHelper;
19
use Zikula\Bundle\CoreBundle\Bundle\MetaData;
20
use Zikula\Bundle\CoreBundle\Bundle\Scanner;
21
use Zikula\Bundle\CoreBundle\HttpKernel\ZikulaHttpKernelInterface;
22
use Zikula\Bundle\CoreBundle\HttpKernel\ZikulaKernel;
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 ZikulaHttpKernelInterface
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 ZikulaHttpKernelInterface $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
        ZikulaHttpKernelInterface $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);
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 name, displayname or url
201
        foreach ($extensions as $dir => $modInfo) {
202
            $fields = ['name', 'displayname', 'url'];
203
            foreach ($fields as $field) {
204
                if (isset($modulenames[strtolower($modInfo[$field])])) {
205
                    throw new FatalErrorException($this->translator->__f('Fatal Error: Two extensions share the same %field. [%ext1%] and [%ext2%]', [
206
                        '%field' => $field,
207
                        '%ext1%' => $modInfo['name'],
208
                        '%ext2%' => $modulenames[strtolower($modInfo['name'])]
209
                    ]));
210
                }
211
            }
212
213
            $modulenames[strtolower($modInfo['name'])] = $dir;
214
            $displaynames[strtolower($modInfo['displayname'])] = $dir;
215
            $urls[strtolower($modInfo['url'])] = $dir;
216
        }
217
    }
218
219
    /**
220
     * Sync extensions in the filesystem and the database.
221
     * @param array $extensionsFromFile
222
     * @param bool $forceDefaults
223
     * @return array $upgradedExtensions[<name>] = <version>
224
     */
225
    public function syncExtensions(array $extensionsFromFile, $forceDefaults = false)
226
    {
227
        // Get all extensions in DB, indexed by name
228
        $extensionsFromDB = $this->extensionRepository->getIndexedArrayCollection('name');
229
230
        // see if any extensions have changed since last regeneration
231
        $this->syncUpdatedExtensions($extensionsFromFile, $extensionsFromDB, $forceDefaults);
232
233
        // See if any extensions have been lost since last sync
234
        $this->syncLostExtensions($extensionsFromFile, $extensionsFromDB);
235
236
        // See any extensions have been gained since last sync,
237
        // or if any current extensions have been upgraded
238
        $upgradedExtensions = $this->syncAddedExtensions($extensionsFromFile, $extensionsFromDB);
239
240
        // Clear and reload the dependencies table with all current dependencies
241
        $this->extensionDependencyRepository->reloadExtensionDependencies($extensionsFromFile);
242
243
        return $upgradedExtensions;
244
    }
245
246
    /**
247
     * Sync extensions that are already in the Database.
248
     *  - update from old names
249
     *  - update compatibility
250
     *  - update user settings (or reset to defaults)
251
     *  - ensure current core compatibility
252
     *
253
     * @param array $extensionsFromFile
254
     * @param array $extensionsFromDB
255
     * @param bool $forceDefaults
256
     */
257
    private function syncUpdatedExtensions(array $extensionsFromFile, array &$extensionsFromDB, $forceDefaults = false)
258
    {
259
        foreach ($extensionsFromFile as $name => $extensionFromFile) {
260
            foreach ($extensionsFromDB as $dbname => $extensionFromDB) {
261
                if (isset($extensionFromDB['name']) && in_array($extensionFromDB['name'], (array)$extensionFromFile['oldnames'])) {
262
                    // migrate its modvars
263
                    $this->extensionVarRepository->updateName($dbname, $name);
264
                    // rename the module register
265
                    $this->extensionRepository->updateName($dbname, $name);
266
                    // replace the old module 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'] = $extensionsFromDB[$name]['state'] - ExtensionApi::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 ($extensionsFromDB[$name]['state'] != ExtensionApi::STATE_UNINITIALISED && $extensionsFromDB[$name]['state'] != ExtensionApi::STATE_INVALID) {
282
                    unset($extensionFromFile['version']);
283
                }
284
                if (!$forceDefaults) {
285
                    unset($extensionFromFile['displayname']);
286
                    unset($extensionFromFile['description']);
287
                    unset($extensionFromFile['url']);
288
                }
289
290
                unset($extensionFromFile['oldnames']);
291
                unset($extensionFromFile['dependencies']);
292
                $extensionFromFile['capabilities'] = unserialize($extensionFromFile['capabilities']);
293
                $extensionFromFile['securityschema'] = unserialize($extensionFromFile['securityschema']);
294
                $extension = $this->extensionRepository->find($extensionFromFile['id']);
295
                $extension->merge($extensionFromFile);
296
                $this->extensionRepository->persistAndFlush($extension);
297
            }
298
299
            // check extension core requirement is compatible with current core
300
            $coreCompatibility = isset($extensionFromFile['corecompatibility'])
301
                ? $extensionFromFile['corecompatibility']
302
                : $this->formatCoreCompatibilityString($extensionFromFile['core_min'], $extensionFromFile['core_max']);
303
            if (isset($extensionsFromDB[$name])) {
304 View Code Duplication
                if (!Semver::satisfies(ZikulaKernel::VERSION, $coreCompatibility)) {
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...
305
                    // extension is incompatible with current core
306
                    $extensionsFromDB[$name]['state'] = $extensionsFromDB[$name]['state'] + ExtensionApi::INCOMPATIBLE_CORE_SHIFT;
307
                    $this->extensionStateHelper->updateState($extensionsFromDB[$name]['id'], $extensionsFromDB[$name]['state']);
308
                }
309
                if (isset($extensionsFromDB[$name]['state'])) {
310
                    $extensionFromFile['state'] = $extensionsFromDB[$name]['state'];
311
                }
312
            }
313
        }
314
    }
315
316
    /**
317
     * Remove extensions from the DB that have been removed from the filesystem.
318
     *
319
     * @param array $extensionsFromFile
320
     * @param array $extensionsFromDB
321
     */
322
    private function syncLostExtensions(array $extensionsFromFile, array &$extensionsFromDB)
323
    {
324
        foreach ($extensionsFromDB as $name => $unusedVariable) {
325
            if (array_key_exists($name, $extensionsFromFile)) {
326
                continue;
327
            }
328
329
            $lostModule = $this->extensionRepository->get($name); // must obtain Entity because value from $extensionsFromDB is only an array
330
            if (!$lostModule) {
331
                throw new \RuntimeException($this->translator->__f('Error! Could not load data for module %s.', [$name]));
332
            }
333
            $lostModuleState = $lostModule->getState();
334
            if (($lostModuleState == ExtensionApi::STATE_INVALID)
335
                || ($lostModuleState == ExtensionApi::STATE_INVALID + ExtensionApi::INCOMPATIBLE_CORE_SHIFT)) {
336
                // extension was invalid and subsequently removed from file system,
337
                // or extension was incompatible with core and subsequently removed, delete it
338
                $this->extensionRepository->removeAndFlush($lostModule);
339
            } elseif (($lostModuleState == ExtensionApi::STATE_UNINITIALISED)
340
                || ($lostModuleState == ExtensionApi::STATE_UNINITIALISED + ExtensionApi::INCOMPATIBLE_CORE_SHIFT)) {
341
                // extension was uninitialised and subsequently removed from file system, delete it
342
                $this->extensionRepository->removeAndFlush($lostModule);
343
            } else {
344
                // Set state of module to 'missing'
345
                // This state cannot be reached in with an ACTIVE bundle. - ACTIVE bundles are part of the pre-compiled Kernel.
346
                // extensions that are inactive can be marked as missing.
347
                $this->extensionStateHelper->updateState($lostModule->getId(), ExtensionApi::STATE_MISSING);
348
            }
349
350
            unset($extensionsFromDB[$name]);
351
        }
352
    }
353
354
    /**
355
     * Add extensions to the DB that have been added to the filesystem.
356
     *  - add uninitialized extensions
357
     *  - update missing or invalid extensions
358
     *
359
     * @param array $extensionsFromFile
360
     * @param array $extensionsFromDB
361
     * @return array $upgradedExtensions[<name>] => <version>
362
     */
363
    private function syncAddedExtensions(array $extensionsFromFile, array $extensionsFromDB)
364
    {
365
        $upgradedExtensions = [];
366
367
        foreach ($extensionsFromFile as $name => $extensionFromFile) {
368
            if (empty($extensionsFromDB[$name])) {
369
                $extensionFromFile['state'] = ExtensionApi::STATE_UNINITIALISED;
370
                if (!$extensionFromFile['version']) {
371
                    // set state to invalid if we can't determine a version
372
                    $extensionFromFile['state'] = ExtensionApi::STATE_INVALID;
373
                } else {
374
                    $coreCompatibility = isset($extensionFromFile['corecompatibility'])
375
                        ? $extensionFromFile['corecompatibility']
376
                        : $this->formatCoreCompatibilityString($extensionFromFile['core_min'], $extensionFromFile['core_max']);
377
                    // shift state if module is incompatible with core version
378
                    $extensionFromFile['state'] = Semver::satisfies(ZikulaKernel::VERSION, $coreCompatibility)
379
                        ? $extensionFromFile['state']
380
                        : $extensionFromFile['state'] + ExtensionApi::INCOMPATIBLE_CORE_SHIFT;
381
                }
382
383
                // unset vars that don't matter
384
                unset($extensionFromFile['oldnames']);
385
                unset($extensionFromFile['dependencies']);
386
387
                // unserialize vars
388
                $extensionFromFile['capabilities'] = unserialize($extensionFromFile['capabilities']);
389
                $extensionFromFile['securityschema'] = unserialize($extensionFromFile['securityschema']);
390
391
                // insert new module to db
392
                $newExtension = new ExtensionEntity();
393
                $newExtension->merge($extensionFromFile);
394
                $vetoEvent = new GenericEvent($newExtension);
395
                $this->dispatcher->dispatch(ExtensionEvents::INSERT_VETO, $vetoEvent);
396
                if (!$vetoEvent->isPropagationStopped()) {
397
                    $this->extensionRepository->persistAndFlush($newExtension);
398
                }
399
            } else {
400
                // extension is in the db already
401
                if (($extensionsFromDB[$name]['state'] == ExtensionApi::STATE_MISSING)
402
                    || ($extensionsFromDB[$name]['state'] == ExtensionApi::STATE_MISSING + ExtensionApi::INCOMPATIBLE_CORE_SHIFT)) {
403
                    // extension was lost, now it is here again
404
                    $this->extensionStateHelper->updateState($extensionsFromDB[$name]['id'], ExtensionApi::STATE_INACTIVE);
405
                } elseif ((($extensionsFromDB[$name]['state'] == ExtensionApi::STATE_INVALID)
406
                        || ($extensionsFromDB[$name]['state'] == ExtensionApi::STATE_INVALID + ExtensionApi::INCOMPATIBLE_CORE_SHIFT))
407
                    && $extensionFromFile['version']) {
408
                    $coreCompatibility = isset($extensionFromFile['corecompatibility'])
409
                        ? $extensionFromFile['corecompatibility']
410
                        : $this->formatCoreCompatibilityString($extensionFromFile['core_min'], $extensionFromFile['core_max']);
411
                    if (Semver::satisfies(ZikulaKernel::VERSION, $coreCompatibility)) {
412
                        // extension was invalid, now it is valid
413
                        $this->extensionStateHelper->updateState($extensionsFromDB[$name]['id'], ExtensionApi::STATE_UNINITIALISED);
414
                    }
415
                }
416
417
                if ($extensionsFromDB[$name]['version'] != $extensionFromFile['version']) {
418 View Code Duplication
                    if ($extensionsFromDB[$name]['state'] != ExtensionApi::STATE_UNINITIALISED &&
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...
419
                        $extensionsFromDB[$name]['state'] != ExtensionApi::STATE_INVALID) {
420
                        $this->extensionStateHelper->updateState($extensionsFromDB[$name]['id'], ExtensionApi::STATE_UPGRADED);
421
                        $upgradedExtensions[$name] = $extensionFromFile['version'];
422
                    }
423
                }
424
            }
425
        }
426
427
        return $upgradedExtensions;
428
    }
429
430
    /**
431
     * Format a compatibility string suitable for semver comparison using vierbergenlars/php-semver
432
     *
433
     * @param null $coreMin
434
     * @param null $coreMax
435
     * @return string
436
     */
437
    private function formatCoreCompatibilityString($coreMin = null, $coreMax = null)
438
    {
439
        $coreMin = !empty($coreMin) ? $coreMin : '1.4.0';
440
        $coreMax = !empty($coreMax) ? $coreMax : '2.9.99';
441
442
        return $coreMin . ' - ' . $coreMax;
443
    }
444
}
445