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