Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.92% covered (success)
95.92%
47 / 49
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Assets
95.92% covered (success)
95.92%
47 / 49
60.00% covered (warning)
60.00%
3 / 5
16
0.00% covered (danger)
0.00%
0 / 1
 asset
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 vendor
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 serve
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 sendFile
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 notModified
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller\Panel;
6
7use Celema\Core\Exception\HttpNotFound;
8use Celema\Core\Factory\Factory;
9use Celema\Core\Request;
10use Celema\Core\Response;
11use Cosray\Exception\RuntimeException;
12use Cosray\Panel\Client;
13use Cosray\Plugin\Assets as PluginAssets;
14use Cosray\Util\Path;
15
16final class Assets extends Panel
17{
18    private const array PUBLIC_EXTENSIONS = [
19        'css',
20        'js',
21        'mjs',
22        'svg',
23        'png',
24        'jpg',
25        'jpeg',
26        'webp',
27        'woff2',
28        'json',
29        'map',
30    ];
31
32    /**
33     * The panel's own browser files from the package. The version segment
34     * only decides how long a response may be cached; any version serves the
35     * installed files.
36     */
37    public function asset(Request $request, Factory $factory, string $version, string $slug): Response
38    {
39        $client = $this->client();
40        $cacheControl = $client->immutable($version) ? 'public, max-age=31536000, immutable' : 'no-cache';
41
42        if ($slug === Client::SPRITE) {
43            $sprite = $client->sprite();
44            $etag = '"' . md5($sprite) . '"';
45
46            return (
47                $this->notModified($request, $factory, $cacheControl, $etag)
48                    ?? Response::create($factory)
49                        ->header('Cache-Control', $cacheControl)
50                        ->header('ETag', $etag)
51                        ->header('Content-Type', 'image/svg+xml')
52                        ->write($sprite)
53            );
54        }
55
56        $file = $client->file($slug);
57
58        if ($file === null) {
59            throw new HttpNotFound($request);
60        }
61
62        return $this->sendFile($request, $factory, $file, $cacheControl);
63    }
64
65    public function vendor(Request $request, Factory $factory, string $plugin, string $slug): Response
66    {
67        $dir = $this->container->instance(PluginAssets::class)->dir($plugin);
68
69        if ($dir === null) {
70            throw new HttpNotFound($request);
71        }
72
73        return $this->serve($request, $factory, $dir, $slug, self::PUBLIC_EXTENSIONS);
74    }
75
76    /** @param list<string> $extensions */
77    private function serve(
78        Request $request,
79        Factory $factory,
80        string $root,
81        string $slug,
82        array $extensions = ['css', 'js', 'svg'],
83    ): Response {
84        try {
85            $file = Path::inside($root, $slug, checkIsFile: true);
86        } catch (RuntimeException $e) {
87            throw new HttpNotFound($request, previous: $e);
88        }
89
90        $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
91
92        if (!in_array($ext, $extensions, true)) {
93            throw new HttpNotFound($request);
94        }
95
96        return $this->sendFile($request, $factory, $file, 'private, max-age=3600');
97    }
98
99    private function sendFile(Request $request, Factory $factory, string $file, string $cacheControl): Response
100    {
101        $etag = md5_file($file);
102        $lastModified = filemtime($file);
103
104        if ($etag === false || $lastModified === false) {
105            throw new HttpNotFound($request);
106        }
107
108        $etag = '"' . $etag . '"';
109        $notModified = $this->notModified($request, $factory, $cacheControl, $etag);
110        $lastModified = gmdate('D, d M Y H:i:s', $lastModified) . ' GMT';
111
112        if ($notModified !== null) {
113            return $notModified->header('Last-Modified', $lastModified);
114        }
115
116        return Response::create($factory)
117            ->header('Cache-Control', $cacheControl)
118            ->header('ETag', $etag)
119            ->header('Last-Modified', $lastModified)
120            ->file($file);
121    }
122
123    /** A 304 when the client already has this revision, null otherwise. */
124    private function notModified(Request $request, Factory $factory, string $cacheControl, string $etag): ?Response
125    {
126        $ifNoneMatch = array_map('trim', explode(',', $request->header('If-None-Match')));
127
128        if (!in_array('*', $ifNoneMatch, true) && !in_array($etag, $ifNoneMatch, true)) {
129            return null;
130        }
131
132        return Response::create($factory)
133            ->header('Cache-Control', $cacheControl)
134            ->header('ETag', $etag)
135            ->status(304);
136    }
137}