Passed
Push — master ( 18db24...82ddf4 )
by Alexander
02:21
created

AssetPublisher::setFileMode()   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
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
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 26
    public function getAssetUrl(AssetBundle $bundle, string $assetPath): string
173
    {
174 26
        $asset = AssetUtil::resolveAsset($bundle, $assetPath, $this->assetMap);
175
176 26
        if (!empty($asset)) {
177 1
            $assetPath = $asset;
178
        }
179
180 26
        if (!$bundle->cdn) {
181 26
            $this->checkBasePath($bundle->basePath);
182 26
            $this->checkBaseUrl($bundle->baseUrl);
183
        }
184
185 26
        if (!AssetUtil::isRelative($assetPath) || strncmp($assetPath, '/', 1) === 0) {
186 2
            return $assetPath;
187
        }
188
189 24
        if (!is_file("$this->basePath/$assetPath")) {
190 1
            throw new InvalidConfigException("Asset files not found: '$this->basePath/$assetPath.'");
191
        }
192
193 23
        if ($this->appendTimestamp  && ($timestamp = FileHelper::lastModifiedTime("$this->basePath/$assetPath")) > 0) {
194 1
            return "$this->baseUrl/$assetPath?v=$timestamp";
195
        }
196
197 22
        return "$this->baseUrl/$assetPath";
198
    }
199
200
    /**
201
     * Return config linkAssets.
202
     *
203
     * @return bool
204
     */
205 3
    public function getLinkAssets(): bool
206
    {
207 3
        return $this->linkAssets;
208
    }
209
210
    /**
211
     * Loads asset bundle class by name.
212
     *
213
     * @param string $name bundle name.
214
     * @param array $config bundle object configuration.
215
     *
216
     * @throws InvalidConfigException
217
     *
218
     * @return AssetBundle
219
     */
220 34
    public function loadBundle(string $name, array $config = []): AssetBundle
221
    {
222
        /**
223
         * @var AssetBundle $bundle
224
         * @psalm-var class-string $name
225
         */
226 34
        $bundle = new $name();
227
228 34
        foreach ($config as $property => $value) {
229 16
            $bundle->$property = $value;
230
        }
231
232 34
        $bundle->cssOptions = array_merge($bundle->cssOptions, $this->cssDefaultOptions);
233 34
        $bundle->jsOptions = array_merge($bundle->jsOptions, $this->jsDefaultOptions);
234
235 34
        if (!$bundle->cdn) {
236 34
            $this->checkBasePath($bundle->basePath);
237 33
            $this->checkBaseUrl($bundle->baseUrl);
238
        }
239
240 32
        if (!empty($bundle->sourcePath)) {
241 7
            [$bundle->basePath, $bundle->baseUrl] = ($this->publish($bundle));
242
        }
243
244 32
        return $bundle;
245
    }
246
247
    /**
248
     * Publishes a file or a directory.
249
     *
250
     * This method will copy the specified file or directory to {@see basePath} so that it can be accessed via the Web
251
     * server.
252
     *
253
     * If the asset is a file, its file modification time will be checked to avoid unnecessary file copying.
254
     *
255
     * If the asset is a directory, all files and subdirectories under it will be published recursively. Note, in case
256
     * $forceCopy is false the method only checks the existence of the target directory to avoid repetitive copying
257
     * (which is very expensive).
258
     *
259
     * By default, when publishing a directory, subdirectories and files whose name starts with a dot "." will NOT be
260
     * published.
261
     *
262
     * Note: On rare scenario, a race condition can develop that will lead to a  one-time-manifestation of a
263
     * non-critical problem in the creation of the directory that holds the published assets. This problem can be
264
     * avoided altogether by 'requesting' in advance all the resources that are supposed to trigger a 'publish()' call,
265
     * and doing that in the application deployment phase, before system goes live. See more in the following
266
     * discussion: http://code.google.com/p/yii/issues/detail?id=2579
267
     *
268
     * @param AssetBundle $bundle the asset (file or directory) to be read.
269
     *
270
     * - only: array, list of patterns that the file paths should match if they want to be copied.
271
     *
272
     * @throws InvalidConfigException if the asset to be published does not exist.
273
     *
274
     * @return array the path (directory or file path) and the URL that the asset is published as.
275
     */
276 11
    public function publish(AssetBundle $bundle): array
277
    {
278 11
        if (empty($bundle->sourcePath)) {
279 1
            throw new InvalidConfigException(
280 1
                'The sourcePath must be defined in AssetBundle property public ?string $sourcePath = $path.'
281
            );
282
        }
283
284 10
        if (isset($this->published[$bundle->sourcePath])) {
285 1
            return $this->published[$bundle->sourcePath];
286
        }
287
288 10
        $this->checkBasePath($bundle->basePath);
289 10
        $this->checkBaseUrl($bundle->baseUrl);
290
291 10
        if (!file_exists($this->aliases->get($bundle->sourcePath))) {
292 1
            throw new InvalidConfigException("The sourcePath to be published does not exist: $bundle->sourcePath");
293
        }
294
295 9
        return $this->published[$bundle->sourcePath] = $this->publishDirectory(
296 9
            $bundle->sourcePath,
297 9
            $bundle->publishOptions
298
        );
299
    }
300
301
    /**
302
     * Returns the published path of a file path.
303
     *
304
     * This method does not perform any publishing. It merely tells you if the file or directory is published, where it
305
     * will go.
306
     *
307
     * @param string $sourcePath directory or file path being published.
308
     *
309
     * @return string|null string the published file path. Null if the file or directory does not exist
310
     */
311 2
    public function getPublishedPath(string $sourcePath): ?string
312
    {
313 2
        if (isset($this->published[$sourcePath])) {
314 1
            return $this->published[$sourcePath][0];
315
        }
316
317 1
        return null;
318
    }
319
320
    /**
321
     * Returns the URL of a published file path.
322
     *
323
     * This method does not perform any publishing. It merely tells you if the file path is published, what the URL will
324
     * be to access it.
325
     *
326
     * @param string $sourcePath directory or file path being published
327
     *
328
     * @return string|null string the published URL for the file or directory. Null if the file or directory does not
329
     * exist.
330
     */
331 2
    public function getPublishedUrl(string $sourcePath): ?string
332
    {
333 2
        if (isset($this->published[$sourcePath])) {
334 1
            return $this->published[$sourcePath][1];
335
        }
336
337 1
        return null;
338
    }
339
340
    /**
341
     * Append a timestamp to the URL of every published asset.
342
     *
343
     * @param bool $value
344
     *
345
     * {@see appendTimestamp}
346
     */
347 66
    public function setAppendTimestamp(bool $value): void
348
    {
349 66
        $this->appendTimestamp = $value;
350 66
    }
351
352
    /**
353
     * Mapping from source asset files (keys) to target asset files (values).
354
     *
355
     * @param array $value
356
     *
357
     * {@see assetMap}
358
     */
359 66
    public function setAssetMap(array $value): void
360
    {
361 66
        $this->assetMap = $value;
362 66
    }
363
364
    /**
365
     * The root directory storing the published asset files.
366
     *
367
     * @param string|null $value
368
     *
369
     * {@see basePath}
370
     */
371 66
    public function setBasePath(?string $value): void
372
    {
373 66
        $this->basePath = $value;
374 66
    }
375
376
    /**
377
     * The base URL through which the published asset files can be accessed.
378
     *
379
     * @param string|null $value
380
     *
381
     * {@see baseUrl}
382
     */
383 66
    public function setBaseUrl(?string $value): void
384
    {
385 66
        $this->baseUrl = $value;
386 66
    }
387
388
    /**
389
     * The global $css default options for all assets bundle.
390
     *
391
     * @param array $value
392
     *
393
     * {@see $cssDefaultOptions}
394
     */
395 1
    public function setCssDefaultOptions(array $value): void
396
    {
397 1
        $this->cssDefaultOptions = $value;
398 1
    }
399
400
    /**
401
     * The global $js default options for all assets bundle.
402
     *
403
     * @param array $value
404
     *
405
     * {@see $jsDefaultOptions}
406
     */
407 1
    public function setJsDefaultOptions(array $value): void
408
    {
409 1
        $this->jsDefaultOptions = $value;
410 1
    }
411
412
    /**
413
     * The permission to be set for newly generated asset directories.
414
     *
415
     * @param int $value
416
     *
417
     * {@see dirMode}
418
     */
419 1
    public function setDirMode(int $value): void
420
    {
421 1
        $this->dirMode = $value;
422 1
    }
423
424
    /**
425
     * The permission to be set for newly published asset files.
426
     *
427
     * @param int $value
428
     *
429
     * {@see fileMode}
430
     */
431 1
    public function setFileMode(int $value): void
432
    {
433 1
        $this->fileMode = $value;
434 1
    }
435
436
    /**
437
     * Whether the directory being published should be copied even if it is found in the target directory.
438
     *
439
     * @param bool $value
440
     *
441
     * {@see forceCopy}
442
     */
443 66
    public function setForceCopy(bool $value): void
444
    {
445 66
        $this->forceCopy = $value;
446 66
    }
447
448
    /**
449
     * A callback that will be called to produce hash for asset directory generation.
450
     *
451
     * @param callable $value
452
     *
453
     * {@see hashCallback}
454
     */
455 2
    public function setHashCallback(callable $value): void
456
    {
457 2
        $this->hashCallback = $value;
458 2
    }
459
460
    /**
461
     * Whether to use symbolic link to publish asset files.
462
     *
463
     * @param bool $value
464
     *
465
     * {@see linkAssets}
466
     */
467 66
    public function setLinkAssets(bool $value): void
468
    {
469 66
        $this->linkAssets = $value;
470 66
    }
471
472
    /**
473
     * Verify the {@see basePath} of AssetPublisher and AssetBundle is valid.
474
     *
475
     * @param string|null $basePath
476
     *
477
     * @throws InvalidConfigException
478
     */
479 37
    private function checkBasePath(?string $basePath): void
480
    {
481 37
        if (empty($this->basePath) && empty($basePath)) {
482 1
            throw new InvalidConfigException(
483
                'basePath must be set in AssetPublisher->setBasePath($path) or ' .
484 1
                'AssetBundle property public ?string $basePath = $path'
485
            );
486
        }
487
488 36
        if (!empty($basePath)) {
489 35
            $this->basePath = $this->aliases->get($basePath);
490
        }
491 36
    }
492
493
    /**
494
     * Verify the {@see baseUrl} of AssetPublisher and AssetBundle is valid.
495
     *
496
     * @param string|null $baseUrl
497
     *
498
     * @throws InvalidConfigException
499
     */
500 36
    private function checkBaseUrl(?string $baseUrl): void
501
    {
502 36
        if (!isset($this->baseUrl) && $baseUrl === null) {
503 1
            throw new InvalidConfigException(
504
                'baseUrl must be set in AssetPublisher->setBaseUrl($path) or ' .
505 1
                'AssetBundle property public ?string $baseUrl = $path'
506
            );
507
        }
508
509 35
        if ($baseUrl !== null) {
510 35
            $this->baseUrl = $this->aliases->get($baseUrl);
511
        }
512 35
    }
513
514
    /**
515
     * Generate a CRC32 hash for the directory path. Collisions are higher than MD5 but generates a much smaller hash
516
     * string.
517
     *
518
     * @param string $path string to be hashed.
519
     *
520
     * @return string hashed string.
521
     */
522 9
    private function hash(string $path): string
523
    {
524 9
        if (is_callable($this->hashCallback)) {
525 2
            return ($this->hashCallback)($path);
526
        }
527
528 7
        $path = (is_file($path) ? dirname($path) : $path) . FileHelper::lastModifiedTime($path);
529
530 7
        return sprintf('%x', crc32($path . '|' . $this->linkAssets));
531
    }
532
533
    /**
534
     * Publishes a directory.
535
     *
536
     * @param string $src the asset directory to be published
537
     * @param array $options the options to be applied when publishing a directory. The following options are
538
     * supported:
539
     *
540
     * - only: patterns that the file paths should match if they want to be copied.
541
     *
542
     * @throws Exception if the asset to be published does not exist.
543
     *
544
     * @return array the path directory and the URL that the asset is published as.
545
     */
546 9
    private function publishDirectory(string $src, array $options): array
547
    {
548 9
        $src = $this->aliases->get($src);
549 9
        $dir = $this->hash($src);
550 9
        $dstDir = $this->basePath . '/' . $dir;
551
552 9
        if ($this->linkAssets) {
553 1
            if (!is_dir($dstDir)) {
554 1
                FileHelper::createDirectory(dirname($dstDir), $this->dirMode);
555
                try { // fix #6226 symlinking multi threaded
556 1
                    symlink($src, $dstDir);
557
                } catch (Exception $e) {
558
                    if (!is_dir($dstDir)) {
559 1
                        throw $e;
560
                    }
561
                }
562
            }
563
        } elseif (
564 8
            !empty($options['forceCopy']) ||
565 8
            ($this->forceCopy && !isset($options['forceCopy'])) ||
566 8
            !is_dir($dstDir)
567
        ) {
568 8
            $opts = array_merge(
569 8
                $options,
570
                [
571 8
                    'dirMode' => $this->dirMode,
572 8
                    'fileMode' => $this->fileMode,
573
                    'copyEmptyDirectories' => false,
574
                ]
575
            );
576
577 8
            FileHelper::copyDirectory($src, $dstDir, $opts);
578
        }
579
580 9
        return [$dstDir, $this->baseUrl . '/' . $dir];
581
    }
582
}
583