Passed
Push — master ( bc294f...0f6f4c )
by Alexander
03:09
created

AssetPublisher::getBundleBaseUrl()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 1
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 2
rs 10
c 0
b 0
f 0
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 66
    public function __construct(Aliases $aliases)
154
    {
155 66
        $this->aliases = $aliases;
156 66
    }
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 27
    public function getAssetUrl(AssetBundle $bundle, string $assetPath): string
173
    {
174 27
        $this->checkBundleData($bundle);
175
176 25
        $asset = AssetUtil::resolveAsset($bundle, $assetPath, $this->assetMap);
177
178 25
        if (!empty($asset)) {
179 1
            $assetPath = $asset;
180
        }
181
182 25
        if ($bundle->cdn) {
183
            return $this->baseUrl . '/' . $assetPath;
184
        }
185
186 25
        if (!AssetUtil::isRelative($assetPath) || strncmp($assetPath, '/', 1) === 0) {
187 2
            return $assetPath;
188
        }
189
190 23
        $path = $this->getBundleBasePath($bundle) . '/' . $assetPath;
191 23
        $url = $this->getBundleBaseUrl($bundle) . '/' . $assetPath;
192
193 23
        if (!is_file($path)) {
194 1
            throw new InvalidConfigException("Asset files not found: '$path'.");
195
        }
196
197 22
        if ($this->appendTimestamp && ($timestamp = FileHelper::lastModifiedTime("$path")) > 0) {
198 1
            return $url . '?v=' . $timestamp;
199
        }
200
201 21
        return $url;
202
    }
203
204
    /**
205
     * Return config linkAssets.
206
     *
207
     * @return bool
208
     */
209 3
    public function getLinkAssets(): bool
210
    {
211 3
        return $this->linkAssets;
212
    }
213
214
    /**
215
     * Loads asset bundle class by name.
216
     *
217
     * @param string $name bundle name.
218
     * @param array $config bundle object configuration.
219
     *
220
     * @throws InvalidConfigException
221
     *
222
     * @return AssetBundle
223
     */
224 33
    public function loadBundle(string $name, array $config = []): AssetBundle
225
    {
226
        /**
227
         * @var AssetBundle $bundle
228
         * @psalm-var class-string $name
229
         */
230 33
        $bundle = new $name();
231
232 33
        foreach ($config as $property => $value) {
233 15
            $bundle->$property = $value;
234
        }
235
236 33
        $bundle->cssOptions = array_merge($bundle->cssOptions, $this->cssDefaultOptions);
237 33
        $bundle->jsOptions = array_merge($bundle->jsOptions, $this->jsDefaultOptions);
238
239 33
        if ($bundle->cdn) {
240
            return $bundle;
241
        }
242
243 33
        if (!empty($bundle->sourcePath)) {
244 7
            [$bundle->basePath, $bundle->baseUrl] = $this->publish($bundle);
245
        }
246
247 33
        return $bundle;
248
    }
249
250
    /**
251
     * Publishes a file or a directory.
252
     *
253
     * This method will copy the specified file or directory to {@see basePath} so that it can be accessed via the Web
254
     * server.
255
     *
256
     * If the asset is a file, its file modification time will be checked to avoid unnecessary file copying.
257
     *
258
     * If the asset is a directory, all files and subdirectories under it will be published recursively. Note, in case
259
     * $forceCopy is false the method only checks the existence of the target directory to avoid repetitive copying
260
     * (which is very expensive).
261
     *
262
     * By default, when publishing a directory, subdirectories and files whose name starts with a dot "." will NOT be
263
     * published.
264
     *
265
     * Note: On rare scenario, a race condition can develop that will lead to a  one-time-manifestation of a
266
     * non-critical problem in the creation of the directory that holds the published assets. This problem can be
267
     * avoided altogether by 'requesting' in advance all the resources that are supposed to trigger a 'publish()' call,
268
     * and doing that in the application deployment phase, before system goes live. See more in the following
269
     * discussion: http://code.google.com/p/yii/issues/detail?id=2579
270
     *
271
     * @param AssetBundle $bundle the asset (file or directory) to be read.
272
     *
273
     * - only: array, list of patterns that the file paths should match if they want to be copied.
274
     *
275
     * @throws InvalidConfigException if the asset to be published does not exist.
276
     *
277
     * @return array the path (directory or file path) and the URL that the asset is published as.
278
     */
279 12
    public function publish(AssetBundle $bundle): array
280
    {
281 12
        if (empty($bundle->sourcePath)) {
282 1
            throw new InvalidConfigException(
283 1
                'The sourcePath must be defined in AssetBundle property public ?string $sourcePath = $path.'
284
            );
285
        }
286
287 11
        if (isset($this->published[$bundle->sourcePath])) {
288 1
            return $this->published[$bundle->sourcePath];
289
        }
290
291 11
        $this->checkBundleData($bundle);
292
293 11
        if (!file_exists($this->aliases->get($bundle->sourcePath))) {
294 1
            throw new InvalidConfigException("The sourcePath to be published does not exist: $bundle->sourcePath");
295
        }
296
297 10
        return $this->published[$bundle->sourcePath] = $this->publishBundleDirectory($bundle);
298
    }
299
300
    /**
301
     * Returns the published path of a file path.
302
     *
303
     * This method does not perform any publishing. It merely tells you if the file or directory is published, where it
304
     * will go.
305
     *
306
     * @param string $sourcePath directory or file path being published.
307
     *
308
     * @return string|null string the published file path. Null if the file or directory does not exist
309
     */
310 2
    public function getPublishedPath(string $sourcePath): ?string
311
    {
312 2
        if (isset($this->published[$sourcePath])) {
313 1
            return $this->published[$sourcePath][0];
314
        }
315
316 1
        return null;
317
    }
318
319
    /**
320
     * Returns the URL of a published file path.
321
     *
322
     * This method does not perform any publishing. It merely tells you if the file path is published, what the URL will
323
     * be to access it.
324
     *
325
     * @param string $sourcePath directory or file path being published
326
     *
327
     * @return string|null string the published URL for the file or directory. Null if the file or directory does not
328
     * exist.
329
     */
330 2
    public function getPublishedUrl(string $sourcePath): ?string
331
    {
332 2
        if (isset($this->published[$sourcePath])) {
333 1
            return $this->published[$sourcePath][1];
334
        }
335
336 1
        return null;
337
    }
338
339
    /**
340
     * Append a timestamp to the URL of every published asset.
341
     *
342
     * @param bool $value
343
     *
344
     * {@see appendTimestamp}
345
     */
346 66
    public function setAppendTimestamp(bool $value): void
347
    {
348 66
        $this->appendTimestamp = $value;
349 66
    }
350
351
    /**
352
     * Mapping from source asset files (keys) to target asset files (values).
353
     *
354
     * @param array $value
355
     *
356
     * {@see assetMap}
357
     */
358 66
    public function setAssetMap(array $value): void
359
    {
360 66
        $this->assetMap = $value;
361 66
    }
362
363
    /**
364
     * The root directory storing the published asset files.
365
     *
366
     * @param string|null $value
367
     *
368
     * {@see basePath}
369
     */
370 66
    public function setBasePath(?string $value): void
371
    {
372 66
        $this->basePath = $value;
373 66
    }
374
375
    /**
376
     * The base URL through which the published asset files can be accessed.
377
     *
378
     * @param string|null $value
379
     *
380
     * {@see baseUrl}
381
     */
382 66
    public function setBaseUrl(?string $value): void
383
    {
384 66
        $this->baseUrl = $value;
385 66
    }
386
387
    /**
388
     * The global $css default options for all assets bundle.
389
     *
390
     * @param array $value
391
     *
392
     * {@see $cssDefaultOptions}
393
     */
394 1
    public function setCssDefaultOptions(array $value): void
395
    {
396 1
        $this->cssDefaultOptions = $value;
397 1
    }
398
399
    /**
400
     * The global $js default options for all assets bundle.
401
     *
402
     * @param array $value
403
     *
404
     * {@see $jsDefaultOptions}
405
     */
406 1
    public function setJsDefaultOptions(array $value): void
407
    {
408 1
        $this->jsDefaultOptions = $value;
409 1
    }
410
411
    /**
412
     * The permission to be set for newly generated asset directories.
413
     *
414
     * @param int $value
415
     *
416
     * {@see dirMode}
417
     */
418 1
    public function setDirMode(int $value): void
419
    {
420 1
        $this->dirMode = $value;
421 1
    }
422
423
    /**
424
     * The permission to be set for newly published asset files.
425
     *
426
     * @param int $value
427
     *
428
     * {@see fileMode}
429
     */
430 1
    public function setFileMode(int $value): void
431
    {
432 1
        $this->fileMode = $value;
433 1
    }
434
435
    /**
436
     * Whether the directory being published should be copied even if it is found in the target directory.
437
     *
438
     * @param bool $value
439
     *
440
     * {@see forceCopy}
441
     */
442 66
    public function setForceCopy(bool $value): void
443
    {
444 66
        $this->forceCopy = $value;
445 66
    }
446
447
    /**
448
     * A callback that will be called to produce hash for asset directory generation.
449
     *
450
     * @param callable $value
451
     *
452
     * {@see hashCallback}
453
     */
454 2
    public function setHashCallback(callable $value): void
455
    {
456 2
        $this->hashCallback = $value;
457 2
    }
458
459
    /**
460
     * Whether to use symbolic link to publish asset files.
461
     *
462
     * @param bool $value
463
     *
464
     * {@see linkAssets}
465
     */
466 66
    public function setLinkAssets(bool $value): void
467
    {
468 66
        $this->linkAssets = $value;
469 66
    }
470
471 27
    private function getBundleBasePath(AssetBundle $bundle): string
472
    {
473 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

473
        return $this->aliases->get(/** @scrutinizer ignore-type */ empty($bundle->basePath) ? $this->basePath : $bundle->basePath);
Loading history...
474
    }
475
476 27
    private function getBundleBaseUrl(AssetBundle $bundle): string
477
    {
478 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

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

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