Passed
Push — master ( 7a68e6...cf22d1 )
by Alexander
05:18 queued 02:29
created

AssetPublisher   A

Complexity

Total Complexity 28

Size/Duplication

Total Lines 266
Duplicated Lines 0 %

Test Coverage

Coverage 97.22%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 74
c 3
b 0
f 0
dl 0
loc 266
ccs 70
cts 72
cp 0.9722
rs 10
wmc 28

11 Methods

Rating   Name   Duplication   Size   Complexity  
A withForceCopy() 0 5 1
A getPublishedPath() 0 9 2
A withFileMode() 0 5 1
A publish() 0 31 6
B publishBundleDirectory() 0 30 9
A __construct() 0 5 1
A withHashCallback() 0 5 1
A withDirMode() 0 5 1
A getPublishedUrl() 0 9 2
A withLinkAssets() 0 5 1
A hash() 0 11 3
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Assets;
6
7
use Exception;
8
use RecursiveDirectoryIterator;
9
use Yiisoft\Aliases\Aliases;
10
use Yiisoft\Assets\Exception\InvalidConfigException;
11
use Yiisoft\Files\FileHelper;
12
13
use function array_merge;
14
use function crc32;
15
use function dirname;
16
use function file_exists;
17
use function is_callable;
18
use function is_dir;
19
use function is_file;
20
use function sprintf;
21
use function symlink;
22
23
/**
24
 * AssetPublisher is responsible for executing the publication of the assets
25
 * from {@see AssetBundle::$sourcePath} to {@see AssetBundle::$basePath}.
26
 *
27
 * @psalm-type HashCallback = callable(string):string
28
 * @psalm-type PublishedBundle = array{0:non-empty-string,1:non-empty-string}
29
 */
30
final class AssetPublisher implements AssetPublisherInterface
31
{
32
    private Aliases $aliases;
33
34
    /**
35
     * @var bool Whether the directory being published should be copied even if it is found in the target directory.
36
     */
37
    private bool $forceCopy;
38
39
    /**
40
     * @var bool Whether to use symbolic link to publish asset files.
41
     */
42
    private bool $linkAssets;
43
44
    /**
45
     * @var int The permission to be set for newly generated asset directories.
46
     */
47
    private int $dirMode = 0775;
48
49
    /**
50
     * @var int The permission to be set for newly published asset files.
51
     */
52
    private int $fileMode = 0755;
53
54
    /**
55
     * @var callable|null A callback that will be called to produce hash for asset directory generation.
56
     * @psalm-var HashCallback|null
57
     */
58
    private $hashCallback = null;
59
60
    /**
61
     * @var array Contain published {@see AssetsBundle}.
62
     * @psalm-var PublishedBundle[]
63
     */
64
    private array $published = [];
65
66
    /**
67
     * @param Aliases $aliases The aliases instance.
68
     * @param bool $forceCopy Whether the directory being published should be copied even
69
     * if it is found in the target directory. See {@see withForceCopy()}.
70
     * @param bool $linkAssets Whether to use symbolic link to publish asset files. See {@see withLinkAssets()}.
71
     */
72 98
    public function __construct(Aliases $aliases, bool $forceCopy = false, bool $linkAssets = false)
73
    {
74 98
        $this->aliases = $aliases;
75 98
        $this->forceCopy = $forceCopy;
76 98
        $this->linkAssets = $linkAssets;
77
    }
78
79 20
    public function publish(AssetBundle $bundle): array
80
    {
81 20
        if (empty($bundle->sourcePath)) {
82 1
            throw new InvalidConfigException(
83
                'The sourcePath must be defined in AssetBundle property public ?string $sourcePath = $path.',
84
            );
85
        }
86
87 19
        $sourcePath = $this->aliases->get($bundle->sourcePath);
88
89 19
        if (isset($this->published[$sourcePath])) {
90 6
            return $this->published[$sourcePath];
91
        }
92
93 19
        if (empty($bundle->basePath)) {
94 2
            throw new InvalidConfigException(
95
                'The basePath must be defined in AssetBundle property public ?string $basePath = $path.',
96
            );
97
        }
98
99 18
        if ($bundle->baseUrl === null) {
100 1
            throw new InvalidConfigException(
101
                'The baseUrl must be defined in AssetBundle property public ?string $baseUrl = $path.',
102
            );
103
        }
104
105 17
        if (!file_exists($sourcePath)) {
106 1
            throw new InvalidConfigException("The sourcePath to be published does not exist: {$sourcePath}");
107
        }
108
109 16
        return $this->published[$sourcePath] = $this->publishBundleDirectory($bundle);
110
    }
111
112 2
    public function getPublishedPath(string $sourcePath): ?string
113
    {
114 2
        $sourcePath = $this->aliases->get($sourcePath);
115
116 2
        if (isset($this->published[$sourcePath])) {
117 1
            return $this->published[$sourcePath][0];
118
        }
119
120 1
        return null;
121
    }
122
123 2
    public function getPublishedUrl(string $sourcePath): ?string
124
    {
125 2
        $sourcePath = $this->aliases->get($sourcePath);
126
127 2
        if (isset($this->published[$sourcePath])) {
128 1
            return $this->published[$sourcePath][1];
129
        }
130
131 1
        return null;
132
    }
133
134
    /**
135
     * Returns a new instance with the specified directory mode.
136
     *
137
     * @param int $dirMode The permission to be set for newly generated asset directories. This value will be used
138
     * by PHP `chmod()` function. No umask will be applied. Defaults to 0775, meaning the directory is read-writable
139
     * by owner and group, but read-only for other users.
140
     */
141 2
    public function withDirMode(int $dirMode): self
142
    {
143 2
        $new = clone $this;
144 2
        $new->dirMode = $dirMode;
145 2
        return $new;
146
    }
147
148
    /**
149
     * Returns a new instance with the specified files mode.
150
     *
151
     * @param int $fileMode he permission to be set for newly published asset files. This value will be used
152
     * by PHP `chmod()` function. No umask will be applied. If not set, the permission will be determined
153
     * by the current environment.
154
     */
155 2
    public function withFileMode(int $fileMode): self
156
    {
157 2
        $new = clone $this;
158 2
        $new->fileMode = $fileMode;
159 2
        return $new;
160
    }
161
162
    /**
163
     * Returns a new instance with the specified force copy value.
164
     *
165
     * @param bool $forceCopy Whether the directory being published should be copied even if it is found in the target
166
     * directory. This option is used only when publishing a directory. You may want to set this to be `true` during
167
     * the development stage to make sure the published directory is always up-to-date. Do not set this to `true`
168
     * on production servers as it will significantly degrade the performance.
169
     */
170 1
    public function withForceCopy(bool $forceCopy): self
171
    {
172 1
        $new = clone $this;
173 1
        $new->forceCopy = $forceCopy;
174 1
        return $new;
175
    }
176
177
    /**
178
     * Returns a new instance with the specified force hash callback.
179
     *
180
     * @param callable $hashCallback A callback that will be called to produce hash for asset directory generation.
181
     * The signature of the callback should be as follows:
182
     *
183
     * ```
184
     * function (string $path): string;
185
     * ```
186
     *
187
     * Where `$path` is the asset path. Note that the `$path` can be either directory where the asset files reside or a
188
     * single file. For a CSS file that uses relative path in `url()`, the hash implementation should use the directory
189
     * path of the file instead of the file path to include the relative asset files in the copying.
190
     *
191
     * If this is not set, the asset manager will use the default CRC32 and filemtime in the `hash` method.
192
     *
193
     * Example of an implementation using MD4 hash:
194
     *
195
     * ```php
196
     * function (string $path): string {
197
     *     return hash('md4', $path);
198
     * }
199
     * ```
200
     * @psalm-param HashCallback $hashCallback
201
     */
202 3
    public function withHashCallback(callable $hashCallback): self
203
    {
204 3
        $new = clone $this;
205 3
        $new->hashCallback = $hashCallback;
206 3
        return $new;
207
    }
208
209
    /**
210
     * Returns a new instance with the specified link assets value.
211
     *
212
     * @param bool $linkAssets Whether to use symbolic link to publish asset files. Default is `false`,
213
     * meaning asset files are copied to {@see AssetBundle::$basePath}. Using symbolic links has the benefit
214
     * that the published assets will always be consistent with the source assets and there is no copy
215
     * operation required. This is especially useful during development.
216
     *
217
     * However, there are special requirements for hosting environments in order to use symbolic links. In particular,
218
     * symbolic links are supported only on Linux/Unix, and Windows Vista/2008 or greater.
219
     *
220
     * Moreover, some Web servers need to be properly configured so that the linked assets are accessible to Web users.
221
     * For example, for Apache Web server, the following configuration directive should be added for the Web folder:
222
     *
223
     * ```apache
224
     * Options FollowSymLinks
225
     * ```
226
     */
227 2
    public function withLinkAssets(bool $linkAssets): self
228
    {
229 2
        $new = clone $this;
230 2
        $new->linkAssets = $linkAssets;
231 2
        return $new;
232
    }
233
234
    /**
235
     * Generate a CRC32 hash for the directory path. Collisions are higher than MD5 but generates a much smaller hash
236
     * string.
237
     *
238
     * @param string $path The string to be hashed.
239
     *
240
     * @return string The hashed string.
241
     */
242 16
    private function hash(string $path): string
243
    {
244 16
        if (is_callable($this->hashCallback)) {
245 2
            return ($this->hashCallback)($path);
246
        }
247
248 14
        $dirname = is_file($path) ? dirname($path) : $path;
249 14
        $iterator = new RecursiveDirectoryIterator($dirname, RecursiveDirectoryIterator::SKIP_DOTS);
250 14
        $path = $dirname . (string) FileHelper::lastModifiedTime($iterator) . iterator_count($iterator);
251
252 14
        return sprintf('%x', crc32($path . '|' . $this->linkAssets));
253
    }
254
255
    /**
256
     * Publishes a bundle directory.
257
     *
258
     * @param AssetBundle $bundle The asset bundle instance.
259
     *
260
     * @throws Exception If the asset to be published does not exist.
261
     *
262
     * @return array The path directory and the URL that the asset is published as.
263
     *
264
     * @psalm-return PublishedBundle
265
     */
266 16
    private function publishBundleDirectory(AssetBundle $bundle): array
267
    {
268 16
        $src = $this->aliases->get((string) $bundle->sourcePath);
269 16
        $dir = $this->hash($src);
270 16
        $dstDir = "{$this->aliases->get((string) $bundle->basePath)}/{$dir}";
271
272 16
        if ($this->linkAssets) {
273 1
            if (!is_dir($dstDir)) {
274 1
                FileHelper::ensureDirectory(dirname($dstDir), $this->dirMode);
275
                try { // fix #6226 symlinking multi threaded
276 1
                    symlink($src, $dstDir);
277
                } catch (Exception $e) {
278
                    if (!is_dir($dstDir)) {
279 1
                        throw $e;
280
                    }
281
                }
282
            }
283
        } elseif (
284 15
            !empty($bundle->publishOptions['forceCopy'])
285 15
            || ($this->forceCopy && !isset($bundle->publishOptions['forceCopy']))
286 15
            || !is_dir($dstDir)
287
        ) {
288 15
            FileHelper::copyDirectory($src, $dstDir, array_merge($bundle->publishOptions, [
289 15
                'dirMode' => $this->dirMode,
290 15
                'fileMode' => $this->fileMode,
291
                'copyEmptyDirectories' => false,
292
            ]));
293
        }
294
295 16
        return [$dstDir, "{$this->aliases->get((string) $bundle->baseUrl)}/{$dir}"];
296
    }
297
}
298