Lines
91.56%
76 / 83
Methods
84.61%
11 / 13
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| __construct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| uploadTheme | 81.81% | 27 / 33 | 0.00% | 0 / 1 | 14.02 | ||
| activateTheme | 100.00% | 8 / 8 | 100.00% | 1 / 1 | 2 | ||
| activateDefaultTheme | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 2 | ||
| getThemeRootPath | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| themeStoragePath | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 2 | ||
| assertValidThemeName | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 2 | ||
| assertArchiveIsReadable | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 3 | ||
| normalizeArchivePath | 92.85% | 13 / 14 | 0.00% | 0 / 1 | 5.01 | ||
| assertAllowedFileExtension | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 2 | ||
| assertAllowedTwigTemplate | 100.00% | 9 / 9 | 100.00% | 1 / 1 | 4 | ||
| isAllowedFileExtension | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| isTwigTemplate | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| 27 | readonly class ThemeManager | |
| 28 | { | |
| 29 | public function __construct( | |
| 30 | private Configuration $configuration, | |
| 31 | private StorageInterface $storage, | |
| 32 | private string $themeRootPath = 'themes', | |
| 33 | ) { | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * Validates and uploads a ZIP-based theme into the configured storage. | |
| 38 | * | |
| 39 | * @throws RuntimeException | |
| 40 | */ | |
| 41 | public function uploadTheme(string $themeName, string $archivePath): int | |
| 42 | { | |
| 43 | $this->assertValidThemeName($themeName); | |
| 44 | $this->assertArchiveIsReadable($archivePath); | |
| 45 | ||
| 46 | if (!class_exists(ZipArchive::class)) { | |
| 47 | throw new RuntimeException('Theme upload requires the PHP zip extension (ZipArchive).'); | |
| 48 | } | |
| 49 | ||
| 50 | $zip = new ZipArchive(); | |
| 51 | if ($zip->open($archivePath) !== true) { | |
| 52 | throw new RuntimeException('Failed to open theme archive.'); | |
| 53 | } | |
| 54 | ||
| 55 | $containsIndexTemplate = false; | |
| 56 | ||
| 57 | /** @var array<string, string> */ | |
| 58 | $validatedEntries = []; | |
| 59 | ||
| 60 | try { | |
| 61 | for ($index = 0; $index < $zip->numFiles; $index++) { | |
| 62 | $entryName = $zip->getNameIndex($index); | |
| 63 | if ($entryName === false || $entryName === '' || str_ends_with($entryName, '/')) { | |
| 64 | continue; | |
| 65 | } | |
| 66 | ||
| 67 | $normalizedEntryPath = $this->normalizeArchivePath($entryName, $themeName); | |
| 68 | if ($normalizedEntryPath === '') { | |
| 69 | continue; | |
| 70 | } | |
| 71 | ||
| 72 | $this->assertAllowedFileExtension($normalizedEntryPath); | |
| 73 | ||
| 74 | $contents = $zip->getFromIndex($index); | |
| 75 | if (!is_string($contents)) { | |
| 76 | throw new RuntimeException(sprintf('Failed to read archive entry "%s".', $entryName)); | |
| 77 | } | |
| 78 | ||
| 79 | $this->assertAllowedTwigTemplate($normalizedEntryPath, $contents); | |
| 80 | ||
| 81 | if ($normalizedEntryPath === 'index.twig') { | |
| 82 | $containsIndexTemplate = true; | |
| 83 | } | |
| 84 | ||
| 85 | $storagePath = $this->themeStoragePath($themeName, $normalizedEntryPath); | |
| 86 | $validatedEntries[$storagePath] = $contents; | |
| 87 | } | |
| 88 | } finally { | |
| 89 | $zip->close(); | |
| 90 | } | |
| 91 | ||
| 92 | if (!$containsIndexTemplate) { | |
| 93 | throw new RuntimeException('Invalid theme archive: missing required "index.twig".'); | |
| 94 | } | |
| 95 | ||
| 96 | if ($validatedEntries === []) { | |
| 97 | throw new RuntimeException('Theme archive does not contain uploadable files.'); | |
| 98 | } | |
| 99 | ||
| 100 | foreach ($validatedEntries as $storagePath => $contents) { | |
| 101 | $this->storage->put($storagePath, $contents); | |
| 102 | } | |
| 103 | ||
| 104 | return count($validatedEntries); | |
| 105 | } | |
| 106 | ||
| 107 | public function activateTheme(string $themeName): bool | |
| 108 | { | |
| 109 | $this->assertValidThemeName($themeName); | |
| 110 | ||
| 111 | $indexPath = $this->themeStoragePath($themeName, 'index.twig'); | |
| 112 | if (!$this->storage->exists($indexPath)) { | |
| 113 | throw new RuntimeException(sprintf( | |
| 114 | 'Cannot activate theme "%s": missing required "index.twig".', | |
| 115 | $themeName, | |
| 116 | )); | |
| 117 | } | |
| 118 | ||
| 119 | return $this->configuration->set('layout.templateSet', $themeName); | |
| 120 | } | |
| 121 | ||
| 122 | public function activateDefaultTheme(): bool | |
| 123 | { | |
| 124 | $indexPath = $this->themeStoragePath('default', 'index.twig'); | |
| 125 | if (!$this->storage->exists($indexPath)) { | |
| 126 | return false; | |
| 127 | } | |
| 128 | ||
| 129 | return $this->configuration->set('layout.templateSet', 'default'); | |
| 130 | } | |
| 131 | ||
| 132 | public function getThemeRootPath(): string | |
| 133 | { | |
| 134 | return trim(string: $this->themeRootPath, characters: '/'); | |
| 135 | } | |
| 136 | ||
| 137 | private function themeStoragePath(string $themeName, string $relativePath = ''): string | |
| 138 | { | |
| 139 | $basePath = trim(string: $this->themeRootPath, characters: '/') . '/' . $themeName; | |
| 140 | $relativePath = ltrim(string: $relativePath, characters: '/'); | |
| 141 | ||
| 142 | if ($relativePath === '') { | |
| 143 | return $basePath; | |
| 144 | } | |
| 145 | ||
| 146 | return $basePath . '/' . $relativePath; | |
| 147 | } | |
| 148 | ||
| 149 | private function assertValidThemeName(string $themeName): void | |
| 150 | { | |
| 151 | if (!preg_match('/^[A-Za-z0-9_-]{2,64}$/', $themeName)) { | |
| 152 | throw new RuntimeException('Invalid theme name. Allowed: letters, numbers, "_" and "-".'); | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | private function assertArchiveIsReadable(string $archivePath): void | |
| 157 | { | |
| 158 | if (!is_file($archivePath) || !is_readable($archivePath)) { | |
| 159 | throw new RuntimeException('Theme archive file is missing or not readable.'); | |
| 160 | } | |
| 161 | } | |
| 162 | ||
| 163 | private function normalizeArchivePath(string $entryName, string $themeName): string | |
| 164 | { | |
| 165 | $normalizedPath = str_replace(search: '\\', replace: '/', subject: trim($entryName)); | |
| 166 | $normalizedPath = ltrim(string: $normalizedPath, characters: '/'); | |
| 167 | ||
| 168 | // Flatten root folder archives: "mytheme/index.twig" -> "index.twig". | |
| 169 | $pathParts = array_values(array_filter( | |
| 170 | explode('/', $normalizedPath), | |
| 171 | static fn(string $part): bool => $part !== '', | |
| 172 | )); | |
| 173 | if (count(value: $pathParts) > 1 && $pathParts[0] === $themeName) { | |
| 174 | $pathParts = array_slice($pathParts, offset: 1); | |
| 175 | } | |
| 176 | ||
| 177 | $rebuiltPath = implode('/', $pathParts); | |
| 178 | if ($rebuiltPath === '') { | |
| 179 | return ''; | |
| 180 | } | |
| 181 | ||
| 182 | if (str_contains($rebuiltPath, '..')) { | |
| 183 | throw new RuntimeException('Theme archive contains invalid relative paths.'); | |
| 184 | } | |
| 185 | ||
| 186 | return $rebuiltPath; | |
| 187 | } | |
| 188 | ||
| 189 | private function assertAllowedFileExtension(string $path): void | |
| 190 | { | |
| 191 | if (!$this->isAllowedFileExtension($path)) { | |
| 192 | throw new RuntimeException(sprintf('Theme file type is not allowed: %s', $path)); | |
| 193 | } | |
| 194 | } | |
| 195 | ||
| 196 | private function assertAllowedTwigTemplate(string $path, string $contents): void | |
| 197 | { | |
| 198 | if (!$this->isTwigTemplate($path)) { | |
| 199 | return; | |
| 200 | } | |
| 201 | ||
| 202 | if ($path !== 'index.twig') { | |
| 203 | throw new RuntimeException(sprintf( | |
| 204 | 'Only a static "index.twig" file is allowed in uploaded themes: %s', | |
| 205 | $path, | |
| 206 | )); | |
| 207 | } | |
| 208 | ||
| 209 | if (preg_match('/(\{\{|\{%|\{#)/', $contents) === 1) { | |
| 210 | throw new RuntimeException('Uploaded theme templates must not contain Twig syntax.'); | |
| 211 | } | |
| 212 | } | |
| 213 | ||
| 214 | private function isAllowedFileExtension(string $path): bool | |
| 215 | { | |
| 216 | return (bool) preg_match('/\.(twig|css|js|json|png|jpg|jpeg|svg|webp|gif|woff2?|ttf|otf)$/i', $path); | |
| 217 | } | |
| 218 | ||
| 219 | private function isTwigTemplate(string $path): bool | |
| 220 | { | |
| 221 | return str_ends_with(strtolower($path), '.twig'); | |
| 222 | } | |
| 223 | } |