Passed
Push — v1 ( f6b6a9...6b5c66 )
by Andrew
18:02 queued 13:21
created

src/helpers/Manifest.php (1 issue)

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
    const SUPPRESS_ERRORS_FOR_MODULES = [
48
        'styles.js',
49
    ];
50
51
    // Protected Static Properties
52
    // =========================================================================
53
54
    /**
55
     * @var array
56
     */
57
    protected static $files;
58
59
    /**
60
     * @var bool
61
     */
62
    protected static $isHot = false;
63
64
    // Public Static Methods
65
    // =========================================================================
66
67
    /**
68
     * @param array $config
69
     * @param string $moduleName
70
     * @param bool $async
71
     * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
72
     *
73
     * @return string
74
     * @throws NotFoundHttpException
75
     */
76
    public static function getCssModuleTags(array $config, string $moduleName, bool $async, array $attributes = []): string
77
    {
78
        $legacyModule = self::getModule($config, $moduleName, 'legacy', true);
79
        if ($legacyModule === null) {
80
            return '';
81
        }
82
        $lines = [];
83
        if ($async) {
84
            $lines[] = Html::cssFile($legacyModule, array_merge([
85
                'rel' => 'stylesheet',
86
                'media' => 'print',
87
                'onload' => "this.media='all'",
88
            ], $attributes));
89
            $lines[] = Html::cssFile($legacyModule, array_merge([
90
                'rel' => 'stylesheet',
91
                'noscript' => true,
92
            ], $attributes));
93
        } else {
94
            $lines[] = Html::cssFile($legacyModule, array_merge([
95
                'rel' => 'stylesheet',
96
            ], $attributes));
97
        }
98
99
        return implode("\r\n", $lines);
100
    }
101
102
    /**
103
     * @param string $path
104
     * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
105
     *
106
     * @return string
107
     */
108
    public static function getCssInlineTags(string $path, array $attributes = []): string
109
    {
110
        $result = self::getFile($path);
111
        if ($result) {
112
            $config = [];
113
            $nonce = self::getNonce();
114
            if ($nonce !== null) {
115
                $config['nonce'] = $nonce;
116
                self::includeNonce($nonce, 'style-src');
117
            }
118
            $result = Html::style($result, array_merge($config, $attributes));
119
120
            return $result;
121
        }
122
123
        return '';
124
    }
125
126
    /**
127
     * @param array $config
128
     * @param null|string $name
129
     * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
130
     *
131
     * @return string
132
     * @throws \Twig\Error\LoaderError
133
     */
134
    public static function getCriticalCssTags(array $config, $name = null, array $attributes = []): string
135
    {
136
        // Resolve the template name
137
        $template = Craft::$app->getView()->resolveTemplate($name ?? Twigpack::$templateName ?? '');
138
        if ($template) {
139
            $name = self::combinePaths(
140
                pathinfo($template, PATHINFO_DIRNAME),
141
                pathinfo($template, PATHINFO_FILENAME)
142
            );
143
            $dirPrefix = 'templates/';
144
            if (defined('CRAFT_TEMPLATES_PATH')) {
145
                $dirPrefix = CRAFT_TEMPLATES_PATH;
146
            }
147
            $name = strstr($name, $dirPrefix);
148
            $name = (string)str_replace($dirPrefix, '', $name);
149
            $path = self::combinePaths(
150
                    $config['localFiles']['basePath'],
151
                    $config['localFiles']['criticalPrefix'],
152
                    $name
153
                ) . $config['localFiles']['criticalSuffix'];
154
155
            return self::getCssInlineTags($path, $attributes);
156
        }
157
158
        return '';
159
    }
160
161
    /**
162
     * Returns the uglified loadCSS rel=preload Polyfill as per:
163
     * https://github.com/filamentgroup/loadCSS#how-to-use-loadcss-recommended-example
164
     *
165
     * @return string
166
     * @throws \craft\errors\DeprecationException
167
     * @deprecated in 1.2.0
168
     */
169
    public static function getCssRelPreloadPolyfill(): string
170
    {
171
        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.');
172
173
        return '';
174
    }
175
176
    /**
177
     * @param array $config
178
     * @param string $moduleName
179
     * @param bool $async
180
     * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
181
     *
182
     * @return null|string
183
     * @throws NotFoundHttpException
184
     */
185
    public static function getJsModuleTags(array $config, string $moduleName, bool $async, array $attributes = [])
186
    {
187
        $legacyModule = self::getModule($config, $moduleName, 'legacy', true);
188
        if ($legacyModule === null) {
189
            return '';
190
        }
191
        $modernModule = '';
192
        if ($async) {
193
            $modernModule = self::getModule($config, $moduleName, 'modern', true);
194
            if ($modernModule === null) {
195
                return '';
196
            }
197
        }
198
        $lines = [];
199
        if ($async) {
200
            $lines[] = Html::jsFile($modernModule, array_merge([
201
                'type' => 'module',
202
            ], $attributes));
203
            $lines[] = Html::jsFile($legacyModule, array_merge([
204
                'nomodule' => true,
205
            ], $attributes));
206
        } else {
207
            $lines[] = Html::jsFile($legacyModule, array_merge([
208
            ], $attributes));
209
        }
210
211
        return implode("\r\n", $lines);
212
    }
213
214
    /**
215
     * Safari 10.1 supports modules, but does not support the `nomodule`
216
     * attribute - it will load <script nomodule> anyway. This snippet solve
217
     * this problem, but only for script tags that load external code, e.g.:
218
     * <script nomodule src="nomodule.js"></script>
219
     *
220
     * Again: this will **not* # prevent inline script, e.g.:
221
     * <script nomodule>alert('no modules');</script>.
222
     *
223
     * This workaround is possible because Safari supports the non-standard
224
     * 'beforeload' event. This allows us to trap the module and nomodule load.
225
     *
226
     * Note also that `nomodule` is supported in later versions of Safari -
227
     * it's just 10.1 that omits this attribute.
228
     *
229
     * c.f.: https://gist.github.com/samthor/64b114e4a4f539915a95b91ffd340acc
230
     *
231
     * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
232
     *
233
     * @return string
234
     */
235
    public static function getSafariNomoduleFix(array $attributes = []): string
236
    {
237
        $code = /** @lang JavaScript */
238
            <<<EOT
239
!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()}}();
240
EOT;
241
        $config = [];
242
        $nonce = self::getNonce();
243
        if ($nonce !== null) {
244
            $config['nonce'] = $nonce;
245
            self::includeNonce($nonce, 'script-src');
246
        }
247
248
        return Html::script($code, array_merge($config, $attributes));
249
    }
250
251
    /**
252
     * Return the URI to a module
253
     *
254
     * @param array $config
255
     * @param string $moduleName
256
     * @param string $type
257
     * @param bool $soft
258
     *
259
     * @return null|string
260
     * @throws NotFoundHttpException
261
     */
262
    public static function getModule(array $config, string $moduleName, string $type = 'modern', bool $soft = false)
263
    {
264
        // Get the module entry
265
        $module = self::getModuleEntry($config, $moduleName, $type, $soft);
266
        if ($module !== null) {
267
            $prefix = self::$isHot
268
                ? $config['devServer']['publicPath']
269
                : $config['server']['publicPath'];
270
            $useAbsoluteUrl = $config['useAbsoluteUrl'];
271
            // If the module isn't a full URL, prefix it as required
272
            if ($useAbsoluteUrl && !UrlHelper::isAbsoluteUrl($module)) {
273
                $module = self::combinePaths($prefix, $module);
274
            }
275
            // Resolve any aliases
276
            $alias = Craft::getAlias($module, false);
277
            if ($alias) {
278
                $module = $alias;
279
            }
280
            // Make sure it's a full URL, as required
281
            if ($useAbsoluteUrl && !UrlHelper::isAbsoluteUrl($module) && !is_file($module)) {
282
                try {
283
                    $module = UrlHelper::siteUrl($module);
284
                } catch (Exception $e) {
285
                    Craft::error($e->getMessage(), __METHOD__);
286
                }
287
            }
288
        }
289
290
        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...
291
    }
292
293
    /**
294
     * Return the HASH value from to module
295
     *
296
     * @param array $config
297
     * @param string $moduleName
298
     * @param string $type
299
     * @param bool $soft
300
     *
301
     * @return null|string
302
     */
303
    public static function getModuleHash(array $config, string $moduleName, string $type = 'modern', bool $soft = false)
304
    {
305
306
        $moduleHash = '';
307
        try {
308
            // Get the module entry
309
            $module = self::getModuleEntry($config, $moduleName, $type, $soft);
310
            if ($module !== null) {
311
                // Extract only the Hash Value
312
                $modulePath = pathinfo($module);
313
                $moduleFilename = $modulePath['filename'];
314
                $moduleHash = substr($moduleFilename, strpos($moduleFilename, ".") + 1);
315
            }
316
        } catch (Exception $e) {
317
            // return empty string if no module is found
318
            return '';
319
        }
320
321
        return $moduleHash;
322
    }
323
324
    /**
325
     * Return a module's raw entry from the manifest
326
     *
327
     * @param array $config
328
     * @param string $moduleName
329
     * @param string $type
330
     * @param bool $soft
331
     *
332
     * @return null|string
333
     * @throws NotFoundHttpException
334
     */
335
    public static function getModuleEntry(
336
        array $config,
337
        string $moduleName,
338
        string $type = 'modern',
339
        bool $soft = false
340
    )
341
    {
342
        $module = null;
343
        // Get the manifest file
344
        $manifest = self::getManifestFile($config, $type);
345
        if ($manifest !== null) {
346
            // Make sure it exists in the manifest
347
            if (empty($manifest[$moduleName]) && !in_array($moduleName, self::SUPPRESS_ERRORS_FOR_MODULES)) {
348
                self::reportError(Craft::t(
349
                    'twigpack',
350
                    'Module does not exist in the manifest: {moduleName}',
351
                    ['moduleName' => $moduleName]
352
                ), $soft);
353
354
                return null;
355
            }
356
            $module = $manifest[$moduleName];
357
        }
358
359
        return $module;
360
    }
361
362
    /**
363
     * Return a JSON-decoded manifest file
364
     *
365
     * @param array $config
366
     * @param string $type
367
     *
368
     * @return null|array
369
     * @throws NotFoundHttpException
370
     */
371
    public static function getManifestFile(array $config, string $type = 'modern')
372
    {
373
        $manifest = null;
374
        // Determine whether we should use the devServer for HMR or not
375
        $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
376
        self::$isHot = ($devMode && $config['useDevServer']);
377
        // Try to get the manifest
378
        while ($manifest === null) {
379
            $manifestPath = self::$isHot
380
                ? $config['devServer']['manifestPath']
381
                : $config['server']['manifestPath'];
382
            // If this is a dev-server, use the defined build type
383
            $thisType = $type;
384
            if (self::$isHot) {
385
                $thisType = $config['devServerBuildType'] === 'combined'
386
                    ? $thisType
387
                    : $config['devServerBuildType'];
388
            }
389
            // Normalize the path
390
            $path = self::combinePaths($manifestPath, $config['manifest'][$thisType]);
391
            $manifest = self::getJsonFile($path);
392
            // If the manifest isn't found, and it was hot, fall back on non-hot
393
            if ($manifest === null) {
394
                // We couldn't find a manifest; throw an error
395
                self::reportError(Craft::t(
396
                    'twigpack',
397
                    'Manifest file not found at: {manifestPath}',
398
                    ['manifestPath' => $manifestPath]
399
                ), true);
400
                if (self::$isHot) {
401
                    // Try again, but not with home module replacement
402
                    self::$isHot = false;
403
                } else {
404
                    // Give up and return null
405
                    return null;
406
                }
407
            }
408
        }
409
410
        return $manifest;
411
    }
412
413
    /**
414
     * Returns the contents of a file from a URI path
415
     *
416
     * @param string $path
417
     *
418
     * @return string
419
     */
420
    public static function getFile(string $path): string
421
    {
422
        return self::getFileFromUri($path, null, true) ?? '';
423
    }
424
425
    /**
426
     * @param array $config
427
     * @param string $fileName
428
     * @param string $type
429
     *
430
     * @return string
431
     */
432
    public static function getFileFromManifest(array $config, string $fileName, string $type = 'legacy'): string
433
    {
434
        $path = null;
435
        try {
436
            $path = self::getModuleEntry($config, $fileName, $type, true);
437
        } catch (NotFoundHttpException $e) {
438
            Craft::error($e->getMessage(), __METHOD__);
439
        }
440
        if ($path !== null) {
441
            // Determine whether we should use the devServer for HMR or not
442
            $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
443
            if ($devMode) {
444
                $devServerPrefix = $config['devServer']['publicPath'];
445
                $devServerPath = self::combinePaths(
446
                    $devServerPrefix,
447
                    $path
448
                );
449
                $devServerFile = self::getFileFromUri($devServerPath, null);
450
                if ($devServerFile) {
451
                    return $devServerFile;
452
                }
453
            }
454
            // Otherwise, try not-hot files
455
            $localPrefix = $config['localFiles']['basePath'];
456
            $localPath = self::combinePaths(
457
                $localPrefix,
458
                $path
459
            );
460
            $alias = Craft::getAlias($localPath, false);
461
            if ($alias && is_string($alias)) {
462
                $localPath = $alias;
463
            }
464
            if (is_file($localPath)) {
465
                return self::getFile($localPath) ?? '';
466
            }
467
        }
468
469
        return '';
470
    }
471
472
    /**
473
     * Invalidate all of the manifest caches
474
     */
475
    public static function invalidateCaches()
476
    {
477
        $cache = Craft::$app->getCache();
478
        TagDependency::invalidate($cache, self::CACHE_TAG);
479
        Craft::info('All manifest caches cleared', __METHOD__);
480
    }
481
482
    /**
483
     * Return the contents of a JSON file from a URI path
484
     *
485
     * @param string $path
486
     *
487
     * @return null|array
488
     */
489
    protected static function getJsonFile(string $path)
490
    {
491
        return self::getFileFromUri($path, [self::class, 'jsonFileDecode']);
492
    }
493
494
    // Protected Static Methods
495
    // =========================================================================
496
497
    /**
498
     * Return the contents of a file from a URI path
499
     *
500
     * @param string $path
501
     * @param callable|null $callback
502
     * @param bool $pathOnly
503
     *
504
     * @return null|mixed
505
     */
506
    protected static function getFileFromUri(string $path, callable $callback = null, bool $pathOnly = false)
507
    {
508
        // Resolve any aliases
509
        $alias = Craft::getAlias($path, false);
510
        if ($alias && is_string($alias)) {
511
            $path = $alias;
512
        }
513
        // If we only want the file via path, make sure it exists
514
        if ($pathOnly && !is_file($path)) {
515
            Craft::warning(Craft::t(
516
                'twigpack',
517
                'File does not exist: {path}',
518
                ['path' => $path]
519
            ), __METHOD__);
520
521
            return '';
522
        }
523
        // Make sure it's a full URL
524
        if (!UrlHelper::isAbsoluteUrl($path) && !is_file($path)) {
525
            try {
526
                $path = UrlHelper::siteUrl($path);
527
            } catch (Exception $e) {
528
                Craft::error($e->getMessage(), __METHOD__);
529
            }
530
        }
531
532
        return self::getFileContents($path, $callback);
533
    }
534
535
    /**
536
     * Return the contents of a file from the passed in path
537
     *
538
     * @param string $path
539
     * @param callable $callback
540
     *
541
     * @return null|mixed
542
     */
543
    protected static function getFileContents(string $path, callable $callback = null)
544
    {
545
        // Return the memoized manifest if it exists
546
        if (!empty(self::$files[$path])) {
547
            return self::$files[$path];
548
        }
549
        // Create the dependency tags
550
        $dependency = new TagDependency([
551
            'tags' => [
552
                self::CACHE_TAG,
553
                self::CACHE_TAG . $path,
554
            ],
555
        ]);
556
        // Set the cache duration based on devMode
557
        $cacheDuration = Craft::$app->getConfig()->getGeneral()->devMode
558
            ? self::DEVMODE_CACHE_DURATION
559
            : null;
560
        // If we're in `devMode` invalidate the cache immediately
561
        if (Craft::$app->getConfig()->getGeneral()->devMode) {
562
            self::invalidateCaches();
563
        }
564
        // Get the result from the cache, or parse the file
565
        $cache = Craft::$app->getCache();
566
        $settings = Twigpack::$plugin->getSettings();
567
        $cacheKeySuffix = $settings->cacheKeySuffix ?? '';
568
        $file = $cache->getOrSet(
569
            self::CACHE_KEY . $cacheKeySuffix . $path,
570
            function () use ($path, $callback) {
571
                $result = null;
572
                if (UrlHelper::isAbsoluteUrl($path)) {
573
                    /**
574
                     * Silly work-around for what appears to be a file_get_contents bug with https
575
                     * http://stackoverflow.com/questions/10524748/why-im-getting-500-error-when-using-file-get-contents-but-works-in-a-browser
576
                     */
577
                    $opts = [
578
                        'ssl' => [
579
                            'verify_peer' => false,
580
                            'verify_peer_name' => false,
581
                        ],
582
                        'http' => [
583
                            'timeout' => 5,
584
                            'ignore_errors' => true,
585
                            '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",
586
                        ],
587
                    ];
588
                    $context = stream_context_create($opts);
589
                    $contents = @file_get_contents($path, false, $context);
590
                } else {
591
                    $contents = @file_get_contents($path);
592
                }
593
                if ($contents) {
594
                    $result = $contents;
595
                    if ($callback) {
596
                        $result = $callback($result);
597
                    }
598
                }
599
600
                return $result;
601
            },
602
            $cacheDuration,
603
            $dependency
604
        );
605
        self::$files[$path] = $file;
606
607
        return $file;
608
    }
609
610
    /**
611
     * Combined the passed in paths, whether file system or URL
612
     *
613
     * @param string ...$paths
614
     *
615
     * @return string
616
     */
617
    protected static function combinePaths(string ...$paths): string
618
    {
619
        $last_key = count($paths) - 1;
620
        array_walk($paths, function (&$val, $key) use ($last_key) {
621
            switch ($key) {
622
                case 0:
623
                    $val = rtrim($val, '/ ');
624
                    break;
625
                case $last_key:
626
                    $val = ltrim($val, '/ ');
627
                    break;
628
                default:
629
                    $val = trim($val, '/ ');
630
                    break;
631
            }
632
        });
633
634
        $first = array_shift($paths);
635
        $last = array_pop($paths);
636
        $paths = array_filter($paths);
637
        array_unshift($paths, $first);
638
        $paths[] = $last;
639
640
        return implode('/', $paths);
641
    }
642
643
    /**
644
     * @param string $error
645
     * @param bool $soft
646
     *
647
     * @throws NotFoundHttpException
648
     */
649
    protected static function reportError(string $error, $soft = false)
650
    {
651
        $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
652
        if ($devMode && !$soft) {
653
            throw new NotFoundHttpException($error);
654
        }
655
        if (self::$isHot) {
656
            Craft::warning($error, __METHOD__);
657
        } else {
658
            Craft::error($error, __METHOD__);
659
        }
660
    }
661
662
    // Private Static Methods
663
    // =========================================================================
664
665
    /**
666
     * @param string $nonce
667
     * @param string $cspDirective
668
     */
669
    private static function includeNonce(string $nonce, string $cspDirective)
670
    {
671
        $cspNonceType = self::getCspNonceType();
672
        if ($cspNonceType) {
673
            $cspValue = "{$cspDirective} 'nonce-$nonce'";
674
            foreach(self::CSP_HEADERS as $cspHeader) {
675
                switch ($cspNonceType) {
676
                    case 'tag':
677
                        Craft::$app->getView()->registerMetaTag([
678
                            'httpEquiv' => $cspHeader,
679
                            'value' => $cspValue,
680
                        ]);
681
                        break;
682
                    case 'header':
683
                        Craft::$app->getResponse()->getHeaders()->add($cspHeader, $cspValue . ';');
684
                        break;
685
                    default:
686
                        break;
687
                }
688
            }
689
        }
690
    }
691
692
    /**
693
     * @return string|null
694
     */
695
    private static function getCspNonceType()
696
    {
697
        /** @var Settings $settings */
698
        $settings = Twigpack::$plugin->getSettings();
699
        $cspNonceType = !empty($settings->cspNonce) ? strtolower($settings->cspNonce) : null;
700
701
        return $cspNonceType;
702
    }
703
704
    /**
705
     * @return string|null
706
     */
707
    private static function getNonce()
708
    {
709
        $result = null;
710
        if (self::getCspNonceType() !== null) {
711
            try {
712
                $result = bin2hex(random_bytes(22));
713
            } catch (\Exception $e) {
714
                // That's okay
715
            }
716
        }
717
718
        return $result;
719
    }
720
    /**
721
     * @param $string
722
     *
723
     * @return null|array
724
     */
725
    private static function jsonFileDecode($string)
726
    {
727
        $json = JsonHelper::decodeIfJson($string);
728
        if (is_string($json)) {
729
            Craft::error('Error decoding JSON file: ' . $json, __METHOD__);
730
            $json = null;
731
        }
732
733
        return $json;
734
    }
735
}
736