Passed
Push — master ( 5a3114...9212c9 )
by Alexander
06:49
created

AssetPublisher::setLinkAssets()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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