Passed
Push — master ( f73a03...016f54 )
by Sergei
04:19 queued 01:50
created

AssetPublisher::publish()   A

Complexity

Conditions 6
Paths 6

Size

Total Lines 31
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 6

Importance

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