Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.50% covered (warning)
87.50%
91 / 104
58.33% covered (warning)
58.33%
7 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Iconify
87.50% covered (warning)
87.50%
91 / 104
58.33% covered (warning)
58.33%
7 / 12
50.13
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 icon
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 split
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 loadSvg
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
8
 iconUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 query
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 queryArgs
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 queryValue
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 request
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
7.06
 cacheFile
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 cacheDir
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
5.16
 store
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
6.99
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Icons;
6
7use Closure;
8use Cosray\Config;
9use Cosray\Exception\RuntimeException;
10use Cosray\Util\Path;
11use Cosray\Util\Svg;
12use Override;
13
14final class Iconify implements Provider
15{
16    /** @var Closure(string, int, string): ?string */
17    private readonly Closure $fetch;
18
19    public function __construct(
20        private readonly Config $config,
21        ?callable $fetch = null,
22    ) {
23        $this->fetch = $fetch === null
24            ? Closure::fromCallable([$this, 'request'])
25            : Closure::fromCallable($fetch);
26    }
27
28    /** @param array<array-key, mixed> $args */
29    #[Override]
30    public function icon(string $id, array $args = []): string
31    {
32        $parts = $this->split($id);
33
34        if ($parts === null) {
35            return '';
36        }
37
38        $svg = $this->loadSvg($id, $parts['prefix'], $parts['name'], $args);
39
40        return $svg ?? '';
41    }
42
43    /**
44     * @return array{prefix: string, name: string}|null
45     */
46    private function split(string $id): ?array
47    {
48        $id = trim($id);
49
50        if (!preg_match(
51            '/^(?<prefix>[a-z0-9]+(?:[-_][a-z0-9]+)*):(?<name>[a-z0-9]+(?:[-_][a-z0-9]+)*)$/i',
52            $id,
53            $matches,
54        )) {
55            return null;
56        }
57
58        $prefix = strtolower((string) $matches['prefix']);
59        $name = strtolower((string) $matches['name']);
60
61        if ($prefix === '' || $name === '') {
62            return null;
63        }
64
65        return ['prefix' => $prefix, 'name' => $name];
66    }
67
68    /**
69     * Remote markup is inlined into pages, so it is sanitized for inline
70     * use, which also removes `<style>` elements. Cached files are
71     * sanitized again when read: they live in the public directory and may
72     * predate sanitizing.
73     *
74     * @param array<array-key, mixed> $args
75     */
76    private function loadSvg(string $id, string $prefix, string $name, array $args): ?string
77    {
78        $file = $this->cacheFile($id, $args);
79
80        if ($file !== null && is_file($file)) {
81            $cached = file_get_contents($file);
82            $svg = is_string($cached) ? Svg::sanitize($cached, inline: true) : null;
83
84            if ($svg !== null) {
85                return $svg;
86            }
87        }
88
89        $url = $this->iconUrl($prefix, $name, $args);
90        $iconify = $this->config->icons->iconify;
91        $fetched = ($this->fetch)($url, $iconify->timeout, $iconify->userAgent);
92        $svg = is_string($fetched) ? Svg::sanitize($fetched, inline: true) : null;
93
94        if ($svg === null) {
95            return null;
96        }
97
98        if ($file !== null) {
99            $this->store($file, $svg);
100        }
101
102        return $svg;
103    }
104
105    /** @param array<array-key, mixed> $args */
106    private function iconUrl(string $prefix, string $name, array $args): string
107    {
108        $base = rtrim($this->config->icons->iconify->baseUrl, '/');
109        $url = sprintf('%s/%s/%s.svg', $base, rawurlencode($prefix), rawurlencode($name));
110        $query = $this->query($args);
111
112        return $query === '' ? $url : $url . '?' . $query;
113    }
114
115    /** @param array<array-key, mixed> $args */
116    private function query(array $args): string
117    {
118        $args = $this->queryArgs($args);
119
120        if ($args === []) {
121            return '';
122        }
123
124        return http_build_query($args, '', '&', PHP_QUERY_RFC3986);
125    }
126
127    /**
128     * @param array<array-key, mixed> $args
129     * @return array<array-key, mixed>
130     */
131    private function queryArgs(array $args): array
132    {
133        $query = [];
134
135        foreach ($args as $key => $value) {
136            $value = $this->queryValue($value);
137
138            if ($value !== null) {
139                $query[$key] = $value;
140            }
141        }
142
143        if (!array_is_list($query)) {
144            ksort($query, SORT_STRING);
145        }
146
147        return $query;
148    }
149
150    /** @return scalar|array<array-key, mixed>|null */
151    private function queryValue(mixed $value): mixed
152    {
153        if (is_scalar($value)) {
154            return $value;
155        }
156
157        if (is_array($value)) {
158            return $this->queryArgs($value);
159        }
160
161        return null;
162    }
163
164    private function request(string $url, int $timeout, string $userAgent): ?string
165    {
166        $handle = curl_init($url);
167
168        if ($handle === false) {
169            return null;
170        }
171
172        $options = [
173            CURLOPT_RETURNTRANSFER => true,
174            CURLOPT_CONNECTTIMEOUT => $timeout,
175            CURLOPT_TIMEOUT => $timeout,
176            CURLOPT_FOLLOWLOCATION => true,
177            CURLOPT_HTTPHEADER => ['Accept: image/svg+xml,text/plain;q=0.9,*/*;q=0.1'],
178        ];
179
180        if ($userAgent !== '') {
181            $options[CURLOPT_USERAGENT] = $userAgent;
182        }
183
184        curl_setopt_array($handle, $options);
185        $body = curl_exec($handle);
186        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
187        $error = curl_errno($handle);
188
189        if (!is_string($body) || $error !== 0 || $status < 200 || $status >= 300) {
190            return null;
191        }
192
193        return $body;
194    }
195
196    /** @param array<array-key, mixed> $args */
197    private function cacheFile(string $id, array $args): ?string
198    {
199        $dir = $this->cacheDir();
200
201        if ($dir === null) {
202            return null;
203        }
204
205        $query = $this->query($args);
206        $cacheId = $query === '' ? $id : $id . '?' . $query;
207
208        return $dir . DIRECTORY_SEPARATOR . hash('xxh3', $cacheId) . '.svg';
209    }
210
211    private function cacheDir(): ?string
212    {
213        $publicDir = realpath($this->config->path->public);
214
215        if ($publicDir === false) {
216            return null;
217        }
218
219        $cacheDir = $this->config->path->cache;
220
221        if ($cacheDir === '' || str_contains(str_replace('\\', '/', $cacheDir), '..')) {
222            return null;
223        }
224
225        $target =
226            rtrim($publicDir, '\\/')
227            . DIRECTORY_SEPARATOR
228            . ltrim($cacheDir, '\\/')
229            . DIRECTORY_SEPARATOR
230            . 'icons';
231
232        try {
233            Path::ensureDirectory($target);
234        } catch (RuntimeException) {
235            return null;
236        }
237
238        return $target;
239    }
240
241    private function store(string $file, string $svg): void
242    {
243        $temp = $file . '.tmp.' . bin2hex(random_bytes(6));
244
245        if (file_put_contents($temp, $svg, LOCK_EX) === false) {
246            unlink($temp);
247            return;
248        }
249
250        if (!rename($temp, $file)) {
251            if (is_file($temp)) {
252                unlink($temp);
253            }
254        }
255    }
256}