Lines 91.56% 76 / 83
Methods 84.61% 11 / 13
Classes 0.00% 0 / 1
Covered by tests of size
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
27readonly 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}