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