Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
95.92% |
47 / 49 |
|
60.00% |
3 / 5 |
CRAP | |
0.00% |
0 / 1 |
| Assets | |
95.92% |
47 / 49 |
|
60.00% |
3 / 5 |
16 | |
0.00% |
0 / 1 |
| asset | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
4 | |||
| vendor | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| serve | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
| sendFile | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
4.01 | |||
| notModified | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\Controller\Panel; |
| 6 | |
| 7 | use Celema\Core\Exception\HttpNotFound; |
| 8 | use Celema\Core\Factory\Factory; |
| 9 | use Celema\Core\Request; |
| 10 | use Celema\Core\Response; |
| 11 | use Cosray\Exception\RuntimeException; |
| 12 | use Cosray\Panel\Client; |
| 13 | use Cosray\Plugin\Assets as PluginAssets; |
| 14 | use Cosray\Util\Path; |
| 15 | |
| 16 | final 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 | } |