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