Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.57% covered (success)
91.57%
76 / 83
84.62% covered (success)
84.62%
11 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ThemeManager
91.57% covered (success)
91.57%
76 / 83
84.62% covered (success)
84.62%
11 / 13
39.91
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
 uploadTheme
81.82% covered (success)
81.82%
27 / 33
0.00% covered (danger)
0.00%
0 / 1
14.02
 activateTheme
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 activateDefaultTheme
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getThemeRootPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 themeStoragePath
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 assertValidThemeName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 assertArchiveIsReadable
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 normalizeArchivePath
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 assertAllowedFileExtension
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 assertAllowedTwigTemplate
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 isAllowedFileExtension
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isTwigTemplate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Storage-backed theme upload and activation manager.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-02-14
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Template;
21
22use phpMyFAQ\Configuration;
23use phpMyFAQ\Storage\StorageInterface;
24use RuntimeException;
25use ZipArchive;
26
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}