Passed
Push — master ( 5f1548...9fee89 )
by Alexander
02:42
created

AssetPublisher::getLinkAssets()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 1
nc 1
nop 0
dl 0
loc 3
ccs 2
cts 2
cp 1
c 0
b 0
f 0
cc 1
crap 1
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Assets;
6
7
use Exception;
8
use Yiisoft\Aliases\Aliases;
9
use Yiisoft\Assets\Exception\InvalidConfigException;
10
use Yiisoft\Files\FileHelper;
11
12
use function array_merge;
13
use function crc32;
14
use function dirname;
15
use function file_exists;
16
use function is_callable;
17
use function is_dir;
18
use function is_file;
19
use function sprintf;
20
use function strncmp;
21
use function symlink;
22
23
/**
24
 * AssetPublisher is responsible for executing the publication of the assets from {@see sourcePath} to {@see basePath}.
25
 */
26
final class AssetPublisher implements AssetPublisherInterface
27
{
28
    private Aliases $aliases;
29
30
    /**
31
     * @var bool whether to append a timestamp to the URL of every published asset. When this is true, the URL of a
32
     * published asset may look like `/path/to/asset?v=timestamp`, where `timestamp` is the last modification time of
33
     * the published asset file. You normally would want to set this property to true when you have enabled HTTP caching
34
     * for assets, because it allows you to bust caching when the assets are updated.
35
     */
36
    private bool $appendTimestamp = false;
37
38
    /**
39
     * @var array mapping from source asset files (keys) to target asset files (values).
40
     *
41
     * This property is provided to support fixing incorrect asset file paths in some asset bundles. When an asset
42
     * bundle is registered with a view, each relative asset file in its {@see AssetBundle::css|css} and
43
     * {@see AssetBundle::js|js} arrays will be examined against this map. If any of the keys is found to be the last
44
     * part of an asset file (which is prefixed with {@see AssetBundle::sourcePath} if available), the corresponding
45
     * value will replace the asset and be registered with the view. For example, an asset file `my/path/to/jquery.js`
46
     * matches a key `jquery.js`.
47
     *
48
     * Note that the target asset files should be absolute URLs, domain relative URLs (starting from '/') or paths
49
     * relative to {@see baseUrl} and {@see basePath}.
50
     *
51
     * In the following example, any assets ending with `jquery.min.js` will be replaced with `jquery/dist/jquery.js`
52
     * which is relative to {@see baseUrl} and {@see basePath}.
53
     *
54
     * ```php
55
     * [
56
     *     'jquery.min.js' => 'jquery/dist/jquery.js',
57
     * ]
58
     * ```
59
     */
60
    private array $assetMap = [];
61
62
    /**
63
     * @var string|null the root directory storing the published asset files.
64
     */
65
    private ?string $basePath = null;
66
67
    /**
68
     * @var string|null the root directory storing the published asset files.
69
     */
70
    private ?string $baseUrl = null;
71
72
    /**
73
     * @var array the options that will be passed to {@see \Yiisoft\View\View::registerCssFile()} when registering the
74
     * CSS files all assets bundle.
75
     */
76
    private array $cssDefaultOptions = [];
77
78
    /**
79
     * @var array the options that will be passed to {@see \Yiisoft\View\View::registerJsFile()} when registering the
80
     * JS files all assets bundle.
81
     */
82
    private array $jsDefaultOptions = [];
83
84
    /**
85
     * @var int the permission to be set for newly generated asset directories. This value will be used by PHP chmod()
86
     * function. No umask will be applied. Defaults to 0775, meaning the directory is read-writable by owner
87
     * and group, but read-only for other users.
88
     */
89
    private int $dirMode = 0775;
90
91
    /**
92
     * @var int the permission to be set for newly published asset files. This value will be used by PHP chmod()
93
     * function. No umask will be applied. If not set, the permission will be determined by the current
94
     * environment.
95
     */
96
    private int $fileMode = 0755;
97
98
    /**
99
     * @var bool whether the directory being published should be copied even if it is found in the target directory.
100
     * This option is used only when publishing a directory. You may want to set this to be `true` during the
101
     * development stage to make sure the published directory is always up-to-date. Do not set this to true
102
     * on production servers as it will significantly degrade the performance.
103
     */
104
    private bool $forceCopy = false;
105
106
    /**
107
     * @var callable|null a callback that will be called to produce hash for asset directory generation. The signature
108
     * of the callback should be as follows:
109
     *
110
     * ```
111
     * function ($path)
112
     * ```
113
     *
114
     * where `$path` is the asset path. Note that the `$path` can be either directory where the asset files reside or a
115
     * single file. For a CSS file that uses relative path in `url()`, the hash implementation should use the directory
116
     * path of the file instead of the file path to include the relative asset files in the copying.
117
     *
118
     * If this is not set, the asset manager will use the default CRC32 and filemtime in the `hash` method.
119
     *
120
     * Example of an implementation using MD4 hash:
121
     *
122
     * ```php
123
     * function ($path) {
124
     *     return hash('md4', $path);
125
     * }
126
     * ```
127
     */
128
    private $hashCallback = null;
129
130
    /**
131
     * @var bool whether to use symbolic link to publish asset files. Defaults to false, meaning asset files are copied
132
     * to {@see basePath}. Using symbolic links has the benefit that the published assets will always be
133
     * consistent with the source assets and there is no copy operation required. This is especially useful
134
     * during development.
135
     *
136
     * However, there are special requirements for hosting environments in order to use symbolic links. In particular,
137
     * symbolic links are supported only on Linux/Unix, and Windows Vista/2008 or greater.
138
     *
139
     * Moreover, some Web servers need to be properly configured so that the linked assets are accessible to Web users.
140
     * For example, for Apache Web server, the following configuration directive should be added for the Web folder:
141
     *
142
     * ```apache
143
     * Options FollowSymLinks
144
     * ```
145
     */
146
    private bool $linkAssets = false;
147
148
    /**
149
     * @var array Contain published AssetsBundle.
150
     */
151
    private array $published = [];
152
153 67
    public function __construct(Aliases $aliases)
154
    {
155 67
        $this->aliases = $aliases;
156 67
    }
157
158
    /**
159
     * Returns the actual URL for the specified asset.
160
     *
161
     * The actual URL is obtained by prepending either {@see AssetBundle::$baseUrl} or {@see AssetManager::$baseUrl} to
162
     * the given asset path.
163
     *
164
     * @param AssetBundle $bundle the asset bundle which the asset file belongs to.
165
     * @param string $assetPath the asset path. This should be one of the assets listed in {@see AssetBundle::$js} or
166
     * {@see AssetBundle::$css}.
167
     *
168
     * @throws InvalidConfigException
169
     *
170
     * @return string the actual URL for the specified asset.
171
     */
172 28
    public function getAssetUrl(AssetBundle $bundle, string $assetPath): string
173
    {
174 28
        $this->checkBundleData($bundle);
175
176 26
        $asset = AssetUtil::resolveAsset($bundle, $assetPath, $this->assetMap);
177
178 26
        if (!empty($asset)) {
179 1
            $assetPath = $asset;
180
        }
181
182 26
        if ($bundle->cdn) {
183 1
            return $bundle->baseUrl === null
184 1
                ? $assetPath
185 1
                : $bundle->baseUrl . '/' . $assetPath;
186
        }
187
188 25
        if (!AssetUtil::isRelative($assetPath) || strncmp($assetPath, '/', 1) === 0) {
189 2
            return $assetPath;
190
        }
191
192 23
        $path = $this->getBundleBasePath($bundle) . '/' . $assetPath;
193 23
        $url = $this->getBundleBaseUrl($bundle) . '/' . $assetPath;
194
195 23
        if (!is_file($path)) {
196 1
            throw new InvalidConfigException("Asset files not found: '$path'.");
197
        }
198
199 22
        if ($this->appendTimestamp && ($timestamp = FileHelper::lastModifiedTime("$path")) > 0) {
200 1
            return $url . '?v=' . $timestamp;
201
        }
202
203 21
        return $url;
204
    }
205
206
    /**
207
     * Return config linkAssets.
208
     *
209
     * @return bool
210
     */
211 3
    public function getLinkAssets(): bool
212
    {
213 3
        return $this->linkAssets;
214
    }
215
216
    /**
217
     * Loads asset bundle class by name.
218
     *
219
     * @param string $name bundle name.
220
     * @param array $config bundle object configuration.
221
     *
222
     * @throws InvalidConfigException
223
     *
224
     * @return AssetBundle
225
     */
226 33
    public function loadBundle(string $name, array $config = []): AssetBundle
227
    {
228
        /**
229
         * @var AssetBundle $bundle
230
         * @psalm-var class-string $name
231
         */
232 33
        $bundle = new $name();
233
234 33
        foreach ($config as $property => $value) {
235 15
            $bundle->$property = $value;
236
        }
237
238 33
        $bundle->cssOptions = array_merge($bundle->cssOptions, $this->cssDefaultOptions);
239 33
        $bundle->jsOptions = array_merge($bundle->jsOptions, $this->jsDefaultOptions);
240
241 33
        if ($bundle->cdn) {
242
            return $bundle;
243
        }
244
245 33
        if (!empty($bundle->sourcePath)) {
246 7
            [$bundle->basePath, $bundle->baseUrl] = $this->publish($bundle);
247
        }
248
249 33
        return $bundle;
250
    }
251
252
    /**
253
     * Publishes a file or a directory.
254
     *
255
     * This method will copy the specified file or directory to {@see basePath} so that it can be accessed via the Web
256
     * server.
257
     *
258
     * If the asset is a file, its file modification time will be checked to avoid unnecessary file copying.
259
     *
260
     * If the asset is a directory, all files and subdirectories under it will be published recursively. Note, in case
261
     * $forceCopy is false the method only checks the existence of the target directory to avoid repetitive copying
262
     * (which is very expensive).
263
     *
264
     * By default, when publishing a directory, subdirectories and files whose name starts with a dot "." will NOT be
265
     * published.
266
     *
267
     * Note: On rare scenario, a race condition can develop that will lead to a  one-time-manifestation of a
268
     * non-critical problem in the creation of the directory that holds the published assets. This problem can be
269
     * avoided altogether by 'requesting' in advance all the resources that are supposed to trigger a 'publish()' call,
270
     * and doing that in the application deployment phase, before system goes live. See more in the following
271
     * discussion: http://code.google.com/p/yii/issues/detail?id=2579
272
     *
273
     * @param AssetBundle $bundle the asset (file or directory) to be read.
274
     *
275
     * - only: array, list of patterns that the file paths should match if they want to be copied.
276
     *
277
     * @throws InvalidConfigException if the asset to be published does not exist.
278
     *
279
     * @return array the path (directory or file path) and the URL that the asset is published as.
280
     */
281 12
    public function publish(AssetBundle $bundle): array
282
    {
283 12
        if (empty($bundle->sourcePath)) {
284 1
            throw new InvalidConfigException(
285 1
                'The sourcePath must be defined in AssetBundle property public ?string $sourcePath = $path.'
286
            );
287
        }
288
289 11
        if (isset($this->published[$bundle->sourcePath])) {
290 1
            return $this->published[$bundle->sourcePath];
291
        }
292
293 11
        $this->checkBundleData($bundle);
294
295 11
        if (!file_exists($this->aliases->get($bundle->sourcePath))) {
296 1
            throw new InvalidConfigException("The sourcePath to be published does not exist: $bundle->sourcePath");
297
        }
298
299 10
        return $this->published[$bundle->sourcePath] = $this->publishBundleDirectory($bundle);
300
    }
301
302
    /**
303
     * Returns the published path of a file path.
304
     *
305
     * This method does not perform any publishing. It merely tells you if the file or directory is published, where it
306
     * will go.
307
     *
308
     * @param string $sourcePath directory or file path being published.
309
     *
310
     * @return string|null string the published file path. Null if the file or directory does not exist
311
     */
312 2
    public function getPublishedPath(string $sourcePath): ?string
313
    {
314 2
        if (isset($this->published[$sourcePath])) {
315 1
            return $this->published[$sourcePath][0];
316
        }
317
318 1
        return null;
319
    }
320
321
    /**
322
     * Returns the URL of a published file path.
323
     *
324
     * This method does not perform any publishing. It merely tells you if the file path is published, what the URL will
325
     * be to access it.
326
     *
327
     * @param string $sourcePath directory or file path being published
328
     *
329
     * @return string|null string the published URL for the file or directory. Null if the file or directory does not
330
     * exist.
331
     */
332 2
    public function getPublishedUrl(string $sourcePath): ?string
333
    {
334 2
        if (isset($this->published[$sourcePath])) {
335 1
            return $this->published[$sourcePath][1];
336
        }
337
338 1
        return null;
339
    }
340
341
    /**
342
     * Append a timestamp to the URL of every published asset.
343
     *
344
     * @param bool $value
345
     *
346
     * {@see appendTimestamp}
347
     */
348 67
    public function setAppendTimestamp(bool $value): void
349
    {
350 67
        $this->appendTimestamp = $value;
351 67
    }
352
353
    /**
354
     * Mapping from source asset files (keys) to target asset files (values).
355
     *
356
     * @param array $value
357
     *
358
     * {@see assetMap}
359
     */
360 67
    public function setAssetMap(array $value): void
361
    {
362 67
        $this->assetMap = $value;
363 67
    }
364
365
    /**
366
     * The root directory storing the published asset files.
367
     *
368
     * @param string|null $value
369
     *
370
     * {@see basePath}
371
     */
372 67
    public function setBasePath(?string $value): void
373
    {
374 67
        $this->basePath = $value;
375 67
    }
376
377
    /**
378
     * The base URL through which the published asset files can be accessed.
379
     *
380
     * @param string|null $value
381
     *
382
     * {@see baseUrl}
383
     */
384 67
    public function setBaseUrl(?string $value): void
385
    {
386 67
        $this->baseUrl = $value;
387 67
    }
388
389
    /**
390
     * The global $css default options for all assets bundle.
391
     *
392
     * @param array $value
393
     *
394
     * {@see $cssDefaultOptions}
395
     */
396 1
    public function setCssDefaultOptions(array $value): void
397
    {
398 1
        $this->cssDefaultOptions = $value;
399 1
    }
400
401
    /**
402
     * The global $js default options for all assets bundle.
403
     *
404
     * @param array $value
405
     *
406
     * {@see $jsDefaultOptions}
407
     */
408 1
    public function setJsDefaultOptions(array $value): void
409
    {
410 1
        $this->jsDefaultOptions = $value;
411 1
    }
412
413
    /**
414
     * The permission to be set for newly generated asset directories.
415
     *
416
     * @param int $value
417
     *
418
     * {@see dirMode}
419
     */
420 1
    public function setDirMode(int $value): void
421
    {
422 1
        $this->dirMode = $value;
423 1
    }
424
425
    /**
426
     * The permission to be set for newly published asset files.
427
     *
428
     * @param int $value
429
     *
430
     * {@see fileMode}
431
     */
432 1
    public function setFileMode(int $value): void
433
    {
434 1
        $this->fileMode = $value;
435 1
    }
436
437
    /**
438
     * Whether the directory being published should be copied even if it is found in the target directory.
439
     *
440
     * @param bool $value
441
     *
442
     * {@see forceCopy}
443
     */
444 67
    public function setForceCopy(bool $value): void
445
    {
446 67
        $this->forceCopy = $value;
447 67
    }
448
449
    /**
450
     * A callback that will be called to produce hash for asset directory generation.
451
     *
452
     * @param callable $value
453
     *
454
     * {@see hashCallback}
455
     */
456 2
    public function setHashCallback(callable $value): void
457
    {
458 2
        $this->hashCallback = $value;
459 2
    }
460
461
    /**
462
     * Whether to use symbolic link to publish asset files.
463
     *
464
     * @param bool $value
465
     *
466
     * {@see linkAssets}
467
     */
468 67
    public function setLinkAssets(bool $value): void
469
    {
470 67
        $this->linkAssets = $value;
471 67
    }
472
473 27
    private function getBundleBasePath(AssetBundle $bundle): string
474
    {
475 27
        return $this->aliases->get(empty($bundle->basePath) ? $this->basePath : $bundle->basePath);
0 ignored issues
show
Bug introduced by
It seems like empty($bundle->basePath)...ath : $bundle->basePath can also be of type null; however, parameter $alias of Yiisoft\Aliases\Aliases::get() 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

475
        return $this->aliases->get(/** @scrutinizer ignore-type */ empty($bundle->basePath) ? $this->basePath : $bundle->basePath);
Loading history...
476
    }
477
478 27
    private function getBundleBaseUrl(AssetBundle $bundle): string
479
    {
480 27
        return $this->aliases->get($bundle->baseUrl === null ? $this->baseUrl : $bundle->baseUrl);
0 ignored issues
show
Bug introduced by
It seems like $bundle->baseUrl === nul...eUrl : $bundle->baseUrl can also be of type null; however, parameter $alias of Yiisoft\Aliases\Aliases::get() 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

480
        return $this->aliases->get(/** @scrutinizer ignore-type */ $bundle->baseUrl === null ? $this->baseUrl : $bundle->baseUrl);
Loading history...
481
    }
482
483
    /**
484
     * Verify the {@see basePath} and the {@see baseUrl} of AssetPublisher and AssetBundle is valid.
485
     *
486
     * @param AssetBundle $bundle
487
     *
488
     * @throws InvalidConfigException
489
     */
490 33
    private function checkBundleData(AssetBundle $bundle): void
491
    {
492 33
        if (!$bundle->cdn && empty($this->basePath) && empty($bundle->basePath)) {
493 2
            throw new InvalidConfigException(
494
                'basePath must be set in AssetPublisher->setBasePath($path) or ' .
495 2
                'AssetBundle property public ?string $basePath = $path'
496
            );
497
        }
498
499 32
        if (!$bundle->cdn && !isset($this->baseUrl) && $bundle->baseUrl === null) {
500 1
            throw new InvalidConfigException(
501
                'baseUrl must be set in AssetPublisher->setBaseUrl($path) or ' .
502 1
                'AssetBundle property public ?string $baseUrl = $path'
503
            );
504
        }
505 31
    }
506
507
    /**
508
     * Generate a CRC32 hash for the directory path. Collisions are higher than MD5 but generates a much smaller hash
509
     * string.
510
     *
511
     * @param string $path string to be hashed.
512
     *
513
     * @return string hashed string.
514
     */
515 10
    private function hash(string $path): string
516
    {
517 10
        if (is_callable($this->hashCallback)) {
518 2
            return ($this->hashCallback)($path);
519
        }
520
521 8
        $path = (is_file($path) ? dirname($path) : $path) . FileHelper::lastModifiedTime($path);
522
523 8
        return sprintf('%x', crc32($path . '|' . $this->linkAssets));
524
    }
525
526
    /**
527
     * Publishes a bundle directory.
528
     *
529
     * @param AssetBundle $bundle
530
     *
531
     * @throws Exception if the asset to be published does not exist.
532
     *
533
     * @return array the path directory and the URL that the asset is published as.
534
     */
535 10
    private function publishBundleDirectory(AssetBundle $bundle): array
536
    {
537 10
        $src = $this->aliases->get($bundle->sourcePath);
0 ignored issues
show
Bug introduced by
It seems like $bundle->sourcePath can also be of type null; however, parameter $alias of Yiisoft\Aliases\Aliases::get() 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

537
        $src = $this->aliases->get(/** @scrutinizer ignore-type */ $bundle->sourcePath);
Loading history...
538 10
        $dir = $this->hash($src);
539 10
        $dstDir = $this->getBundleBasePath($bundle) . '/' . $dir;
540
541 10
        if ($this->linkAssets) {
542 1
            if (!is_dir($dstDir)) {
543 1
                FileHelper::createDirectory(dirname($dstDir), $this->dirMode);
544
                try { // fix #6226 symlinking multi threaded
545 1
                    symlink($src, $dstDir);
546
                } catch (Exception $e) {
547
                    if (!is_dir($dstDir)) {
548 1
                        throw $e;
549
                    }
550
                }
551
            }
552
        } elseif (
553 9
            !empty($bundle->publishOptions['forceCopy']) ||
554 9
            ($this->forceCopy && !isset($bundle->publishOptions['forceCopy'])) ||
555 9
            !is_dir($dstDir)
556
        ) {
557 9
            $opts = array_merge(
558 9
                $bundle->publishOptions,
559
                [
560 9
                    'dirMode' => $this->dirMode,
561 9
                    'fileMode' => $this->fileMode,
562
                    'copyEmptyDirectories' => false,
563
                ]
564
            );
565
566 9
            FileHelper::copyDirectory($src, $dstDir, $opts);
567
        }
568
569 10
        return [$dstDir, $this->getBundleBaseUrl($bundle) . '/' . $dir];
570
    }
571
}
572