Passed
Push — develop ( 55036c...66a566 )
by Andrew
16:23
created

Manifest::getCspNonceType()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 3
c 0
b 0
f 0
nc 2
nop 0
dl 0
loc 7
rs 10
1
<?php
2
/**
3
 * Twigpack plugin for Craft CMS 3.x
4
 *
5
 * Twigpack is the conduit between Twig and webpack, with manifest.json &
6
 * webpack-dev-server HMR support
7
 *
8
 * @link      https://nystudio107.com/
9
 * @copyright Copyright (c) 2018 nystudio107
10
 */
11
12
namespace nystudio107\twigpack\helpers;
13
14
use nystudio107\twigpack\Twigpack;
15
use nystudio107\twigpack\models\Settings;
16
17
use Craft;
18
use craft\helpers\Html;
19
use craft\helpers\Json as JsonHelper;
20
use craft\helpers\UrlHelper;
21
22
use yii\base\Exception;
23
use yii\caching\TagDependency;
24
use yii\web\NotFoundHttpException;
25
26
/**
27
 * @author    nystudio107
28
 * @package   Twigpack
29
 * @since     1.0.0
30
 */
31
class Manifest
32
{
33
    // Constants
34
    // =========================================================================
35
36
    const CACHE_KEY = 'twigpack';
37
    const CACHE_TAG = 'twigpack';
38
39
    const DEVMODE_CACHE_DURATION = 1;
40
41
    const CSP_HEADERS = [
42
        'Content-Security-Policy',
43
        'X-Content-Security-Policy',
44
        'X-WebKit-CSP',
45
    ];
46
47
    // Protected Static Properties
48
    // =========================================================================
49
50
    /**
51
     * @var array
52
     */
53
    protected static $files;
54
55
    /**
56
     * @var bool
57
     */
58
    protected static $isHot = false;
59
60
    // Public Static Methods
61
    // =========================================================================
62
63
    /**
64
     * @param array $config
65
     * @param string $moduleName
66
     * @param bool $async
67
     *
68
     * @return string
69
     * @throws NotFoundHttpException
70
     */
71
    public static function getCssModuleTags(array $config, string $moduleName, bool $async): string
72
    {
73
        $legacyModule = self::getModule($config, $moduleName, 'legacy', true);
74
        if ($legacyModule === null) {
75
            return '';
76
        }
77
        $lines = [];
78
        if ($async) {
79
            $lines[] = Html::cssFile($legacyModule, [
80
                'rel' => 'stylesheet',
81
                'media' => 'print',
82
                'onload' => "this.media='all'",
83
            ]);
84
            $lines[] = Html::cssFile($legacyModule, [
85
                'rel' => 'stylesheet',
86
                'noscript' => true,
87
            ]);
88
        } else {
89
            $lines[] = Html::cssFile($legacyModule, [
90
                'rel' => 'stylesheet',
91
            ]);
92
        }
93
94
        return implode("\r\n", $lines);
95
    }
96
97
    /**
98
     * @param string $path
99
     *
100
     * @return string
101
     */
102
    public static function getCssInlineTags(string $path): string
103
    {
104
        $result = self::getFile($path);
105
        if ($result) {
106
            $config = [];
107
            $nonce = self::getNonce();
108
            if ($nonce !== null) {
109
                $config['nonce'] = $nonce;
110
                self::includeNonce($nonce, 'style-src');
111
            }
112
            $result = Html::style($result, $config);
113
114
            return $result;
115
        }
116
117
        return '';
118
    }
119
120
    /**
121
     * @param array $config
122
     * @param null|string $name
123
     *
124
     * @return string
125
     * @throws \Twig\Error\LoaderError
126
     */
127
    public static function getCriticalCssTags(array $config, $name = null): string
128
    {
129
        // Resolve the template name
130
        $template = Craft::$app->getView()->resolveTemplate($name ?? Twigpack::$templateName ?? '');
131
        if ($template) {
132
            $name = self::combinePaths(
133
                pathinfo($template, PATHINFO_DIRNAME),
134
                pathinfo($template, PATHINFO_FILENAME)
135
            );
136
            $dirPrefix = 'templates/';
137
            if (defined('CRAFT_TEMPLATES_PATH')) {
138
                $dirPrefix = CRAFT_TEMPLATES_PATH;
0 ignored issues
show
Bug introduced by
The constant nystudio107\twigpack\helpers\CRAFT_TEMPLATES_PATH was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
139
            }
140
            $name = strstr($name, $dirPrefix);
141
            $name = (string)str_replace($dirPrefix, '', $name);
142
            $path = self::combinePaths(
143
                    $config['localFiles']['basePath'],
144
                    $config['localFiles']['criticalPrefix'],
145
                    $name
146
                ) . $config['localFiles']['criticalSuffix'];
147
148
            return self::getCssInlineTags($path);
149
        }
150
151
        return '';
152
    }
153
154
    /**
155
     * Returns the uglified loadCSS rel=preload Polyfill as per:
156
     * https://github.com/filamentgroup/loadCSS#how-to-use-loadcss-recommended-example
157
     * @return string
158
     * @throws \craft\errors\DeprecationException
159
     * @deprecated in 1.2.0
160
     */
161
    public static function getCssRelPreloadPolyfill(): string
162
    {
163
        Craft::$app->getDeprecator()->log('craft.twigpack.includeCssRelPreloadPolyfill()', 'craft.twigpack.includeCssRelPreloadPolyfill() has been deprecated, this function now does nothing. You can safely remove craft.twigpack.includeCssRelPreloadPolyfill() from your templates.');
164
165
        return '';
166
    }
167
168
    /**
169
     * @param array $config
170
     * @param string $moduleName
171
     * @param bool $async
172
     *
173
     * @return null|string
174
     * @throws NotFoundHttpException
175
     */
176
    public static function getJsModuleTags(array $config, string $moduleName, bool $async)
177
    {
178
        $legacyModule = self::getModule($config, $moduleName, 'legacy', true);
179
        if ($legacyModule === null) {
180
            return '';
181
        }
182
        $modernModule = '';
183
        if ($async) {
184
            $modernModule = self::getModule($config, $moduleName, 'modern', true);
185
            if ($modernModule === null) {
186
                return '';
187
            }
188
        }
189
        $lines = [];
190
        if ($async) {
191
            $lines[] = Html::jsFile($modernModule, [
192
                'type' => 'module',
193
            ]);
194
            $lines[] = Html::jsFile($legacyModule, [
195
                'nomodule' => true,
196
            ]);
197
        } else {
198
            $lines[] = Html::jsFile($legacyModule, [
199
            ]);
200
        }
201
202
        return implode("\r\n", $lines);
203
    }
204
205
    /**
206
     * Safari 10.1 supports modules, but does not support the `nomodule`
207
     * attribute - it will load <script nomodule> anyway. This snippet solve
208
     * this problem, but only for script tags that load external code, e.g.:
209
     * <script nomodule src="nomodule.js"></script>
210
     *
211
     * Again: this will **not* # prevent inline script, e.g.:
212
     * <script nomodule>alert('no modules');</script>.
213
     *
214
     * This workaround is possible because Safari supports the non-standard
215
     * 'beforeload' event. This allows us to trap the module and nomodule load.
216
     *
217
     * Note also that `nomodule` is supported in later versions of Safari -
218
     * it's just 10.1 that omits this attribute.
219
     *
220
     * c.f.: https://gist.github.com/samthor/64b114e4a4f539915a95b91ffd340acc
221
     *
222
     * @return string
223
     */
224
    public static function getSafariNomoduleFix(): string
225
    {
226
        $code = /** @lang JavaScript */
227
            <<<EOT
228
!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();
229
EOT;
230
        $config = [];
231
        $nonce = self::getNonce();
232
        if ($nonce !== null) {
233
            $config['nonce'] = $nonce;
234
            self::includeNonce($nonce, 'script-src');
235
        }
236
237
        return Html::script($code, $config);
238
    }
239
240
    /**
241
     * Return the URI to a module
242
     *
243
     * @param array $config
244
     * @param string $moduleName
245
     * @param string $type
246
     * @param bool $soft
247
     *
248
     * @return null|string
249
     * @throws NotFoundHttpException
250
     */
251
    public static function getModule(array $config, string $moduleName, string $type = 'modern', bool $soft = false)
252
    {
253
        // Get the module entry
254
        $module = self::getModuleEntry($config, $moduleName, $type, $soft);
255
        if ($module !== null) {
256
            $prefix = self::$isHot
257
                ? $config['devServer']['publicPath']
258
                : $config['server']['publicPath'];
259
            $useAbsoluteUrl = $config['useAbsoluteUrl'];
260
            // If the module isn't a full URL, prefix it as required
261
            if ($useAbsoluteUrl && !UrlHelper::isAbsoluteUrl($module)) {
262
                $module = self::combinePaths($prefix, $module);
263
            }
264
            // Resolve any aliases
265
            $alias = Craft::getAlias($module, false);
266
            if ($alias) {
267
                $module = $alias;
268
            }
269
            // Make sure it's a full URL, as required
270
            if ($useAbsoluteUrl && !UrlHelper::isAbsoluteUrl($module) && !is_file($module)) {
0 ignored issues
show
Bug introduced by
It seems like $module can also be of type true; however, parameter $url of craft\helpers\UrlHelper::isAbsoluteUrl() 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

270
            if ($useAbsoluteUrl && !UrlHelper::isAbsoluteUrl(/** @scrutinizer ignore-type */ $module) && !is_file($module)) {
Loading history...
Bug introduced by
It seems like $module can also be of type true; however, parameter $filename of is_file() 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

270
            if ($useAbsoluteUrl && !UrlHelper::isAbsoluteUrl($module) && !is_file(/** @scrutinizer ignore-type */ $module)) {
Loading history...
271
                try {
272
                    $module = UrlHelper::siteUrl($module);
0 ignored issues
show
Bug introduced by
It seems like $module can also be of type true; however, parameter $path of craft\helpers\UrlHelper::siteUrl() 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

272
                    $module = UrlHelper::siteUrl(/** @scrutinizer ignore-type */ $module);
Loading history...
273
                } catch (Exception $e) {
274
                    Craft::error($e->getMessage(), __METHOD__);
275
                }
276
            }
277
        }
278
279
        return $module;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $module also could return the type true which is incompatible with the documented return type null|string.
Loading history...
280
    }
281
282
    /**
283
     * Return the HASH value from to module
284
     *
285
     * @param array $config
286
     * @param string $moduleName
287
     * @param string $type
288
     * @param bool $soft
289
     *
290
     * @return null|string
291
     */
292
    public static function getModuleHash(array $config, string $moduleName, string $type = 'modern', bool $soft = false)
293
    {
294
295
        $moduleHash = '';
296
        try {
297
            // Get the module entry
298
            $module = self::getModuleEntry($config, $moduleName, $type, $soft);
299
            if ($module !== null) {
300
                // Extract only the Hash Value
301
                $modulePath = pathinfo($module);
302
                $moduleFilename = $modulePath['filename'];
303
                $moduleHash = substr($moduleFilename, strpos($moduleFilename, ".") + 1);
304
            }
305
        } catch (Exception $e) {
306
            // return empty string if no module is found
307
            return '';
308
        }
309
310
        return $moduleHash;
311
    }
312
313
    /**
314
     * Return a module's raw entry from the manifest
315
     *
316
     * @param array $config
317
     * @param string $moduleName
318
     * @param string $type
319
     * @param bool $soft
320
     *
321
     * @return null|string
322
     * @throws NotFoundHttpException
323
     */
324
    public static function getModuleEntry(
325
        array $config,
326
        string $moduleName,
327
        string $type = 'modern',
328
        bool $soft = false
329
    )
330
    {
331
        $module = null;
332
        // Get the manifest file
333
        $manifest = self::getManifestFile($config, $type);
334
        if ($manifest !== null) {
335
            // Make sure it exists in the manifest
336
            if (empty($manifest[$moduleName])) {
337
                self::reportError(Craft::t(
338
                    'twigpack',
339
                    'Module does not exist in the manifest: {moduleName}',
340
                    ['moduleName' => $moduleName]
341
                ), $soft);
342
343
                return null;
344
            }
345
            $module = $manifest[$moduleName];
346
        }
347
348
        return $module;
349
    }
350
351
    /**
352
     * Return a JSON-decoded manifest file
353
     *
354
     * @param array $config
355
     * @param string $type
356
     *
357
     * @return null|array
358
     * @throws NotFoundHttpException
359
     */
360
    public static function getManifestFile(array $config, string $type = 'modern')
361
    {
362
        $manifest = null;
363
        // Determine whether we should use the devServer for HMR or not
364
        $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
365
        self::$isHot = ($devMode && $config['useDevServer']);
366
        // Try to get the manifest
367
        while ($manifest === null) {
368
            $manifestPath = self::$isHot
369
                ? $config['devServer']['manifestPath']
370
                : $config['server']['manifestPath'];
371
            // If this is a dev-server, use the defined build type
372
            $thisType = $type;
373
            if (self::$isHot) {
374
                $thisType = $config['devServerBuildType'] === 'combined'
375
                    ? $thisType
376
                    : $config['devServerBuildType'];
377
            }
378
            // Normalize the path
379
            $path = self::combinePaths($manifestPath, $config['manifest'][$thisType]);
380
            $manifest = self::getJsonFile($path);
381
            // If the manifest isn't found, and it was hot, fall back on non-hot
382
            if ($manifest === null) {
383
                // We couldn't find a manifest; throw an error
384
                self::reportError(Craft::t(
385
                    'twigpack',
386
                    'Manifest file not found at: {manifestPath}',
387
                    ['manifestPath' => $manifestPath]
388
                ), true);
389
                if (self::$isHot) {
390
                    // Try again, but not with home module replacement
391
                    self::$isHot = false;
392
                } else {
393
                    // Give up and return null
394
                    return null;
395
                }
396
            }
397
        }
398
399
        return $manifest;
400
    }
401
402
    /**
403
     * Returns the contents of a file from a URI path
404
     *
405
     * @param string $path
406
     *
407
     * @return string
408
     */
409
    public static function getFile(string $path): string
410
    {
411
        return self::getFileFromUri($path, null, true) ?? '';
412
    }
413
414
    /**
415
     * @param array $config
416
     * @param string $fileName
417
     * @param string $type
418
     *
419
     * @return string
420
     */
421
    public static function getFileFromManifest(array $config, string $fileName, string $type = 'legacy'): string
422
    {
423
        $path = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $path is dead and can be removed.
Loading history...
424
        try {
425
            $path = self::getModuleEntry($config, $fileName, $type, true);
426
        } catch (NotFoundHttpException $e) {
427
            Craft::error($e->getMessage(), __METHOD__);
428
        }
429
        if ($path !== null) {
430
            // Determine whether we should use the devServer for HMR or not
431
            $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
432
            if ($devMode) {
433
                $devServerPrefix = $config['devServer']['publicPath'];
434
                $devServerPath = self::combinePaths(
435
                    $devServerPrefix,
436
                    $path
437
                );
438
                $devServerFile = self::getFileFromUri($devServerPath, null);
439
                if ($devServerFile) {
440
                    return $devServerFile;
441
                }
442
            }
443
            // Otherwise, try not-hot files
444
            $localPrefix = $config['localFiles']['basePath'];
445
            $localPath = self::combinePaths(
446
                $localPrefix,
447
                $path
448
            );
449
            $alias = Craft::getAlias($localPath, false);
450
            if ($alias && is_string($alias)) {
451
                $localPath = $alias;
452
            }
453
            if (is_file($localPath)) {
454
                return self::getFile($localPath) ?? '';
455
            }
456
        }
457
458
        return '';
459
    }
460
461
    /**
462
     * Invalidate all of the manifest caches
463
     */
464
    public static function invalidateCaches()
465
    {
466
        $cache = Craft::$app->getCache();
467
        TagDependency::invalidate($cache, self::CACHE_TAG);
468
        Craft::info('All manifest caches cleared', __METHOD__);
469
    }
470
471
    /**
472
     * Return the contents of a JSON file from a URI path
473
     *
474
     * @param string $path
475
     *
476
     * @return null|array
477
     */
478
    protected static function getJsonFile(string $path)
479
    {
480
        return self::getFileFromUri($path, [self::class, 'jsonFileDecode']);
481
    }
482
483
    // Protected Static Methods
484
    // =========================================================================
485
486
    /**
487
     * Return the contents of a file from a URI path
488
     *
489
     * @param string $path
490
     * @param callable|null $callback
491
     * @param bool $pathOnly
492
     *
493
     * @return null|mixed
494
     */
495
    protected static function getFileFromUri(string $path, callable $callback = null, bool $pathOnly = false)
496
    {
497
        // Resolve any aliases
498
        $alias = Craft::getAlias($path, false);
499
        if ($alias && is_string($alias)) {
500
            $path = $alias;
501
        }
502
        // If we only want the file via path, make sure it exists
503
        if ($pathOnly && !is_file($path)) {
504
            Craft::warning(Craft::t(
505
                'twigpack',
506
                'File does not exist: {path}',
507
                ['path' => $path]
508
            ), __METHOD__);
509
510
            return '';
511
        }
512
        // Make sure it's a full URL
513
        if (!UrlHelper::isAbsoluteUrl($path) && !is_file($path)) {
514
            try {
515
                $path = UrlHelper::siteUrl($path);
516
            } catch (Exception $e) {
517
                Craft::error($e->getMessage(), __METHOD__);
518
            }
519
        }
520
521
        return self::getFileContents($path, $callback);
522
    }
523
524
    /**
525
     * Return the contents of a file from the passed in path
526
     *
527
     * @param string $path
528
     * @param callable $callback
529
     *
530
     * @return null|mixed
531
     */
532
    protected static function getFileContents(string $path, callable $callback = null)
533
    {
534
        // Return the memoized manifest if it exists
535
        if (!empty(self::$files[$path])) {
536
            return self::$files[$path];
537
        }
538
        // Create the dependency tags
539
        $dependency = new TagDependency([
540
            'tags' => [
541
                self::CACHE_TAG,
542
                self::CACHE_TAG . $path,
543
            ],
544
        ]);
545
        // Set the cache duration based on devMode
546
        $cacheDuration = Craft::$app->getConfig()->getGeneral()->devMode
547
            ? self::DEVMODE_CACHE_DURATION
548
            : null;
549
        // If we're in `devMode` invalidate the cache immediately
550
        if (Craft::$app->getConfig()->getGeneral()->devMode) {
551
            self::invalidateCaches();
552
        }
553
        // Get the result from the cache, or parse the file
554
        $cache = Craft::$app->getCache();
555
        $settings = Twigpack::$plugin->getSettings();
556
        $cacheKeySuffix = $settings->cacheKeySuffix ?? '';
557
        $file = $cache->getOrSet(
558
            self::CACHE_KEY . $cacheKeySuffix . $path,
559
            function () use ($path, $callback) {
560
                $result = null;
561
                if (UrlHelper::isAbsoluteUrl($path)) {
562
                    /**
563
                     * Silly work-around for what appears to be a file_get_contents bug with https
564
                     * http://stackoverflow.com/questions/10524748/why-im-getting-500-error-when-using-file-get-contents-but-works-in-a-browser
565
                     */
566
                    $opts = [
567
                        'ssl' => [
568
                            'verify_peer' => false,
569
                            'verify_peer_name' => false,
570
                        ],
571
                        'http' => [
572
                            'timeout' => 5,
573
                            'ignore_errors' => true,
574
                            'header' => "User-Agent:Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.13) Gecko/20080311 Firefox/2.0.0.13\r\n",
575
                        ],
576
                    ];
577
                    $context = stream_context_create($opts);
578
                    $contents = @file_get_contents($path, false, $context);
579
                } else {
580
                    $contents = @file_get_contents($path);
581
                }
582
                if ($contents) {
583
                    $result = $contents;
584
                    if ($callback) {
585
                        $result = $callback($result);
586
                    }
587
                }
588
589
                return $result;
590
            },
591
            $cacheDuration,
592
            $dependency
593
        );
594
        self::$files[$path] = $file;
595
596
        return $file;
597
    }
598
599
    /**
600
     * Combined the passed in paths, whether file system or URL
601
     *
602
     * @param string ...$paths
603
     *
604
     * @return string
605
     */
606
    protected static function combinePaths(string ...$paths): string
607
    {
608
        $last_key = count($paths) - 1;
609
        array_walk($paths, function (&$val, $key) use ($last_key) {
610
            switch ($key) {
611
                case 0:
612
                    $val = rtrim($val, '/ ');
613
                    break;
614
                case $last_key:
615
                    $val = ltrim($val, '/ ');
616
                    break;
617
                default:
618
                    $val = trim($val, '/ ');
619
                    break;
620
            }
621
        });
622
623
        $first = array_shift($paths);
624
        $last = array_pop($paths);
625
        $paths = array_filter($paths);
626
        array_unshift($paths, $first);
627
        $paths[] = $last;
628
629
        return implode('/', $paths);
630
    }
631
632
    /**
633
     * @param string $error
634
     * @param bool $soft
635
     *
636
     * @throws NotFoundHttpException
637
     */
638
    protected static function reportError(string $error, $soft = false)
639
    {
640
        $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
641
        if ($devMode && !$soft) {
642
            throw new NotFoundHttpException($error);
643
        }
644
        if (self::$isHot) {
645
            Craft::warning($error, __METHOD__);
646
        } else {
647
            Craft::error($error, __METHOD__);
648
        }
649
    }
650
651
    // Private Static Methods
652
    // =========================================================================
653
654
    /**
655
     * @param string $nonce
656
     * @param string $cspDirective
657
     */
658
    private static function includeNonce(string $nonce, string $cspDirective)
659
    {
660
        $cspNonceType = self::getCspNonceType();
661
        if ($cspNonceType) {
662
            $cspValue = "{$cspDirective} '$nonce'";
663
            foreach(self::CSP_HEADERS as $cspHeader) {
664
                switch ($cspNonceType) {
665
                    case 'tag':
666
                        Craft::$app->getView()->registerMetaTag([
667
                            'httpEquiv' => $cspHeader,
668
                            'value' => $cspValue,
669
                        ]);
670
                        break;
671
                    case 'header':
672
                        Craft::$app->getResponse()->getHeaders()->add($cspHeader, $cspValue . ';');
673
                        break;
674
                    default:
675
                        break;
676
                }
677
            }
678
        }
679
    }
680
681
    /**
682
     * @return string|null
683
     */
684
    private static function getCspNonceType()
685
    {
686
        /** @var Settings $settings */
687
        $settings = Twigpack::$plugin->getSettings();
688
        $cspNonceType = !empty($settings->cspNonce) ? strtolower($settings->cspNonce) : null;
689
690
        return $cspNonceType;
691
    }
692
693
    /**
694
     * @return string|null
695
     */
696
    private static function getNonce()
697
    {
698
        $result = null;
699
        if (self::getCspNonceType() !== null) {
700
            try {
701
                $result = bin2hex(random_bytes(22));
702
            } catch (\Exception $e) {
703
                // That's okay
704
            }
705
        }
706
707
        return $result;
708
    }
709
    /**
710
     * @param $string
711
     *
712
     * @return null|array
713
     */
714
    private static function jsonFileDecode($string)
715
    {
716
        $json = JsonHelper::decodeIfJson($string);
717
        if (is_string($json)) {
718
            Craft::error('Error decoding JSON file: ' . $json, __METHOD__);
719
            $json = null;
720
        }
721
722
        return $json;
723
    }
724
}
725