Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.31% covered (success)
95.31%
183 / 192
72.22% covered (warning)
72.22%
13 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Client
95.31% covered (success)
95.31%
183 / 192
72.22% covered (warning)
72.22%
13 / 18
100
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 version
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 immutable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 file
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
10
 files
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
9.01
 importMap
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 scripts
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 preloads
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
14
 sprite
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
7.01
 modules
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
14
 composerDirs
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
9
 composerFile
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 served
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 editable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 contents
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 revision
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 installed
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
12.17
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Panel;
6
7use Composer\InstalledVersions;
8use Cosray\Config;
9use Cosray\Exception\RuntimeException;
10use Cosray\Util\Path;
11use FilesystemIterator;
12use JsonException;
13use OutOfBoundsException;
14use RecursiveDirectoryIterator;
15use RecursiveIteratorIterator;
16
17/**
18 * The panel's browser-side files as they ship in the package: plain ES
19 * modules, stylesheets, icons and the committed third-party modules under
20 * `panel/`, plus the modules other Composer packages ship, such as verba's
21 * runtime. Nothing is built, so the files are served as they are under a
22 * URL that carries the installed revision, which lets browsers cache every
23 * file for good until the next update.
24 */
25final class Client
26{
27    public const string SPRITE = 'icons.svg';
28
29    /** The directories below `panel/` a browser may load from. */
30    private const array DIRS = ['src', 'styles', 'modules', 'icons'];
31    private const array EXTENSIONS = ['js', 'css', 'svg'];
32
33    /** The URL segment for modules that Composer packages ship. */
34    private const string COMPOSER = 'composer';
35
36    /**
37     * A static `import … from '…'`, `import '…'` or `export … from '…'`. The
38     * served modules are formatted, unminified source, where statements start
39     * after whitespace or a brace; `import(` and a JSDoc `@import` never match.
40     */
41    private const string STATIC_IMPORT = '/(?:^|[\s;{}])(?:import|export)\s+(?:[^\'"`;]*?\sfrom\s*)?[\'"]([^\'"\n]+)[\'"]/';
42
43    public readonly string $dir;
44    private ?string $version = null;
45
46    /** @var array{imports: array<string, string>, composer: array<string, string>, scripts: list<string>}|null */
47    private ?array $modules = null;
48
49    /** @var array<string, string>|null */
50    private ?array $composerDirs = null;
51
52    public function __construct(
53        private readonly Config $config,
54        ?string $dir = null,
55    ) {
56        $this->dir = $dir ?? dirname(__DIR__, 2) . '/panel';
57    }
58
59    /**
60     * The URL segment that changes with every installed revision: the start
61     * of the commit Composer installed for Cosray, or a hash of the version
62     * for a package without a commit reference, hashed together with the
63     * revisions of the packages the Composer-shipped modules come from.
64     * Files that change under that commit, in a Git working copy such as a
65     * symlinked path repository or while debugging, get a revision of their
66     * own contents instead.
67     */
68    public function version(): string
69    {
70        return $this->version ??= $this->editable() ? $this->contents() : $this->revision();
71    }
72
73    public function url(string $path = ''): string
74    {
75        return rtrim($this->config->panel->path, '/') . '/assets/' . $this->version() . '/' . ltrim($path, '/');
76    }
77
78    /**
79     * Whether a response for a URL carrying `$version` may be cached forever.
80     * Only the current revision qualifies, so a page rendered before an update
81     * or an edit never pins new content under its old URL.
82     */
83    public function immutable(string $version): bool
84    {
85        return $version === $this->version() && $version !== 'dev';
86    }
87
88    /**
89     * The file a URL path below the version segment names, or null when it is
90     * missing or outside the servable directories and file types.
91     */
92    public function file(string $slug): ?string
93    {
94        $segments = explode('/', $slug);
95
96        if (count($segments) < 2 || !in_array($segments[0], [...self::DIRS, self::COMPOSER], true)) {
97            return null;
98        }
99
100        foreach ($segments as $segment) {
101            if ($segment === '' || $segment === '.' || $segment === '..') {
102                return null;
103            }
104        }
105
106        if (!in_array(strtolower(pathinfo($slug, PATHINFO_EXTENSION)), self::EXTENSIONS, true)) {
107            return null;
108        }
109
110        if ($segments[0] === self::COMPOSER) {
111            return $this->composerFile(implode('/', array_slice($segments, 1)));
112        }
113
114        try {
115            return Path::inside(
116                $this->dir . '/' . $segments[0],
117                implode('/', array_slice($segments, 1)),
118                checkIsFile: true,
119            );
120        } catch (RuntimeException) {
121            return null;
122        }
123    }
124
125    /**
126     * Browser files keyed by their path below the revision segment. Reuse the
127     * HTTP allowlist so publishing cannot expose package internals.
128     *
129     * @return iterable<string, string>
130     */
131    public function files(): iterable
132    {
133        $dirs = [];
134
135        foreach (self::DIRS as $dir) {
136            $dirs[$dir] = $this->dir . '/' . $dir;
137        }
138
139        foreach ($this->composerDirs() as $prefix => $dir) {
140            $dirs[self::COMPOSER . '/' . $prefix] = $dir;
141        }
142
143        foreach ($dirs as $prefix => $dir) {
144            if (!is_dir($dir)) {
145                continue;
146            }
147
148            $paths = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS));
149
150            foreach ($paths as $path) {
151                $slug =
152                    $prefix
153                    . '/'
154                    . str_replace(DIRECTORY_SEPARATOR, '/', substr($path->getPathname(), strlen($dir) + 1));
155                $file = $this->file($slug);
156                $root = realpath($dir);
157
158                if ($file !== null && $root !== false && str_starts_with($file, $root . DIRECTORY_SEPARATOR)) {
159                    yield $slug => $file;
160                }
161            }
162        }
163    }
164
165    /**
166     * The import map for the vendored third-party modules and the modules
167     * Composer packages ship, resolved to versioned URLs.
168     *
169     * @return array{imports: array<string, string>}
170     */
171    public function importMap(): array
172    {
173        $imports = array_map(
174            fn(string $path): string => $this->url('modules/' . $path),
175            $this->modules()['imports'],
176        );
177        $this->composerDirs();
178
179        foreach ($this->modules()['composer'] as $specifier => $path) {
180            $imports[$specifier] = $this->url(self::COMPOSER . '/' . $path);
181        }
182
183        return ['imports' => $imports];
184    }
185
186    /**
187     * Vendored classic scripts, which define globals rather than exports.
188     *
189     * @return list<string>
190     */
191    public function scripts(): array
192    {
193        return array_map(
194            fn(string $path): string => $this->url('modules/' . $path),
195            $this->modules()['scripts'],
196        );
197    }
198
199    /**
200     * Every module `$entry` imports statically, directly or through others, for
201     * `<link rel="modulepreload">`: the browser then fetches the graph at once
202     * instead of one import level after the other. Dynamic imports, such as
203     * the element controls, load on demand and stay out.
204     *
205     * @return list<string>
206     */
207    public function preloads(string $entry = 'src/panel.js'): array
208    {
209        $root = realpath($this->dir);
210        $start = $root === false ? false : realpath($root . '/' . $entry);
211
212        if ($root === false || $start === false) {
213            return [];
214        }
215
216        $imports = $this->modules()['imports'];
217        $composer = $this->modules()['composer'];
218        $seen = [$start => true];
219        $queue = [$start];
220        $urls = [];
221
222        while ($queue !== []) {
223            $file = array_shift($queue);
224            $matches = [];
225            preg_match_all(self::STATIC_IMPORT, (string) file_get_contents($file), $matches);
226
227            foreach ($matches[1] as $specifier) {
228                $target = match (true) {
229                    str_starts_with($specifier, './'), str_starts_with($specifier, '../') => realpath(
230                        dirname($file) . '/' . $specifier,
231                    ),
232                    isset($imports[$specifier]) => realpath($root . '/modules/' . $imports[$specifier]),
233                    isset($composer[$specifier]) => $this->composerFile($composer[$specifier]) ?? false,
234                    default => false,
235                };
236                $path = $target === false ? null : $this->served($target, $root);
237
238                if ($target === false || $path === null || isset($seen[$target])) {
239                    continue;
240                }
241
242                $seen[$target] = true;
243                $queue[] = $target;
244                $urls[] = $this->url($path);
245            }
246        }
247
248        return $urls;
249    }
250
251    /**
252     * All panel icons as one SVG of symbols, so script-built markup can
253     * reference any icon with `<use href="…/icons.svg#name">`.
254     */
255    public function sprite(): string
256    {
257        $symbols = [];
258        $files = glob($this->dir . '/icons/*.svg') ?: [];
259        sort($files);
260
261        foreach ($files as $file) {
262            $name = basename($file, '.svg');
263
264            if (preg_match('/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/', $name) !== 1) {
265                continue;
266            }
267
268            $parts = [];
269
270            if (preg_match('~<svg\b([^>]*)>(.*)</svg>~s', (string) file_get_contents($file), $parts) !== 1) {
271                continue;
272            }
273
274            // The symbol keeps the attributes that shape its drawing; size and
275            // classes belong to each use.
276            $attributes = '';
277
278            foreach (['viewBox', 'fill'] as $attribute) {
279                $match = [];
280
281                if (preg_match('/\s' . $attribute . '="([^"]*)"/', $parts[1], $match) === 1) {
282                    $attributes .= " {$attribute}=\"{$match[1]}\"";
283                }
284            }
285
286            $symbols[] = "<symbol id=\"{$name}\"{$attributes}>" . trim($parts[2]) . '</symbol>';
287        }
288
289        return '<svg xmlns="http://www.w3.org/2000/svg">' . implode('', $symbols) . "</svg>\n";
290    }
291
292    /** @return array{imports: array<string, string>, composer: array<string, string>, scripts: list<string>} */
293    private function modules(): array
294    {
295        if ($this->modules !== null) {
296            return $this->modules;
297        }
298
299        $file = $this->dir . '/modules/importmap.json';
300        $data = [];
301
302        if (is_file($file)) {
303            try {
304                $data = json_decode((string) file_get_contents($file), true, flags: JSON_THROW_ON_ERROR);
305            } catch (JsonException $e) {
306                throw new RuntimeException("Invalid panel import map {$file}: {$e->getMessage()}", previous: $e);
307            }
308        }
309
310        $modules = ['imports' => [], 'composer' => [], 'scripts' => []];
311
312        foreach (['imports', 'composer'] as $key) {
313            if (is_array($data) && is_array($data[$key] ?? null)) {
314                foreach ($data[$key] as $specifier => $path) {
315                    if (is_string($specifier) && is_string($path)) {
316                        $modules[$key][$specifier] = $path;
317                    }
318                }
319            }
320        }
321
322        if (is_array($data) && is_array($data['scripts'] ?? null)) {
323            foreach ($data['scripts'] as $path) {
324                if (is_string($path)) {
325                    $modules['scripts'][] = $path;
326                }
327            }
328        }
329
330        return $this->modules = $modules;
331    }
332
333    /**
334     * The directory each module a Composer package ships is served from, as
335     * the real path keyed by its path below the `composer` URL segment:
336     * `celema/verba/js/src` for `celema/verba/js/src/index.js`. Only that
337     * directory is served, never the rest of the package.
338     *
339     * @return array<string, string>
340     */
341    private function composerDirs(): array
342    {
343        if ($this->composerDirs !== null) {
344            return $this->composerDirs;
345        }
346
347        $dirs = [];
348
349        foreach ($this->modules()['composer'] as $specifier => $path) {
350            $segments = explode('/', $path);
351
352            if (count($segments) < 3 || array_intersect($segments, ['', '.', '..']) !== []) {
353                throw new RuntimeException("Invalid panel module path {$path} for {$specifier}");
354            }
355
356            $package = $segments[0] . '/' . $segments[1];
357
358            try {
359                $installed = InstalledVersions::getInstallPath($package);
360            } catch (OutOfBoundsException) {
361                $installed = null;
362            }
363
364            $file = $installed === null ? false : realpath($installed . '/' . implode('/', array_slice($segments, 2)));
365
366            if ($file === false || !is_file($file)) {
367                throw new RuntimeException(
368                    "The panel module {$specifier} needs {$path}, which the installed {$package} lacks; "
369                        . "run `composer update {$package}`",
370                );
371            }
372
373            $dirs[dirname($path)] = dirname($file);
374        }
375
376        return $this->composerDirs = $dirs;
377    }
378
379    /** The file a path below the `composer` URL segment names, if served. */
380    private function composerFile(string $path): ?string
381    {
382        if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'js') {
383            return null;
384        }
385
386        foreach ($this->composerDirs() as $prefix => $dir) {
387            if (!str_starts_with($path, $prefix . '/')) {
388                continue;
389            }
390
391            try {
392                return Path::inside($dir, substr($path, strlen($prefix) + 1), checkIsFile: true);
393            } catch (RuntimeException) {
394                return null;
395            }
396        }
397
398        return null;
399    }
400
401    /** The URL path below the revision a real file is served under, if any. */
402    private function served(string $file, string $root): ?string
403    {
404        if (str_starts_with($file, $root . DIRECTORY_SEPARATOR)) {
405            return str_replace(DIRECTORY_SEPARATOR, '/', substr($file, strlen($root) + 1));
406        }
407
408        foreach ($this->composerDirs() as $prefix => $dir) {
409            if (str_starts_with($file, $dir . DIRECTORY_SEPARATOR)) {
410                $rest = str_replace(DIRECTORY_SEPARATOR, '/', substr($file, strlen($dir) + 1));
411
412                return self::COMPOSER . '/' . $prefix . '/' . $rest;
413            }
414        }
415
416        return null;
417    }
418
419    /** Whether the files may change without Composer installing anything. */
420    private function editable(): bool
421    {
422        return $this->config->debug() || file_exists(dirname($this->dir) . '/.git');
423    }
424
425    /**
426     * A hash of every servable file's path, size and modification time: any
427     * edit, addition or removal yields new URLs. Modification times count
428     * whole seconds, so a file changed within the last two also adds its
429     * contents; two saves in one second would otherwise share a revision.
430     */
431    private function contents(): string
432    {
433        $files = [];
434        $recent = time() - 2;
435        $dirs = [
436            ...array_map(fn(string $dir): string => $this->dir . '/' . $dir, self::DIRS),
437            ...array_values($this->composerDirs()),
438        ];
439
440        foreach ($dirs as $dir) {
441            if (!is_dir($dir)) {
442                continue;
443            }
444
445            $paths = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(
446                $dir,
447                FilesystemIterator::SKIP_DOTS | FilesystemIterator::CURRENT_AS_PATHNAME,
448            ));
449
450            foreach ($paths as $path) {
451                $stat = stat((string) $path);
452
453                if ($stat === false) {
454                    continue;
455                }
456
457                $file = "{$path}:{$stat['size']}:{$stat['mtime']}";
458                $files[] = $stat['mtime'] >= $recent ? $file . ':' . hash_file('xxh128', (string) $path) : $file;
459            }
460        }
461
462        sort($files);
463
464        return substr(sha1(implode("\n", $files)), 0, 12);
465    }
466
467    /** Cosray's installed revision, combined with those of the module packages. */
468    private function revision(): string
469    {
470        $revision = self::installed('cosray/cms');
471        $packages = [];
472
473        foreach (array_keys($this->composerDirs()) as $prefix) {
474            $packages[] = implode('/', array_slice(explode('/', $prefix), 0, 2));
475        }
476
477        if ($revision === 'dev' || $packages === []) {
478            return $revision;
479        }
480
481        $parts = [$revision];
482
483        foreach (array_unique($packages) as $package) {
484            $parts[] = $package . '@' . self::installed($package);
485        }
486
487        return substr(sha1(implode("\n", $parts)), 0, 12);
488    }
489
490    private static function installed(string $package): string
491    {
492        try {
493            $reference = InstalledVersions::getReference($package);
494            $version = InstalledVersions::getPrettyVersion($package);
495        } catch (OutOfBoundsException) {
496            return 'dev';
497        }
498
499        if ($reference !== null && preg_match('/\A[0-9a-f]{12,}\z/i', $reference) === 1) {
500            return strtolower(substr($reference, 0, 12));
501        }
502
503        if ($version === null || $version === '') {
504            return 'dev';
505        }
506
507        return substr(sha1($version . '@' . ($reference ?? '')), 0, 12);
508    }
509}