Skip to content

feat: added theme manager#3979

Merged
thorsten merged 5 commits intomainfrom
feat/improved-template-system
Feb 14, 2026
Merged

feat: added theme manager#3979
thorsten merged 5 commits intomainfrom
feat/improved-template-system

Conversation

@thorsten
Copy link
Owner

@thorsten thorsten commented Feb 14, 2026

Summary by CodeRabbit

  • New Features

    • Admin UI: upload ZIP themes, validate contents, store and activate themes; activation updates runtime template selection with automatic fallback to default. Secure, CSRF-protected upload API and admin flow.
  • Documentation

    • Added docs covering theme upload workflow, required files, admin steps, activation flow, and security/validation guidance.
  • Tests

    • Added unit tests for theme upload, validation, activation, and template-set resolution.

@coderabbitai
Copy link

coderabbitai bot commented Feb 14, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a ThemeManager service (ZIP upload, validation, storage, activation), admin upload UI and API endpoint with CSRF/permission checks, propagates templateSet through TwigWrapper and tenant provisioning, registers the service, and adds unit tests and docs for theme workflows.

Changes

Cohort / File(s) Summary
Theme Management Core
phpmyfaq/src/phpMyFAQ/Template/ThemeManager.php
New ThemeManager: validates ZIP uploads (theme name, index.twig required, allowed extensions), extracts and writes validated files to StorageInterface, and exposes activateTheme/activateDefaultTheme/getThemeRootPath.
API & Admin UI
phpmyfaq/admin/assets/src/api/configuration.ts, phpmyfaq/admin/assets/src/configuration/configuration.ts, phpmyfaq/assets/templates/admin/configuration/tab-list.twig, phpmyfaq/assets/templates/admin/configuration/themes.upload.twig, phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationTabController.php
Adds client-side upload API and handler, admin upload form fragment, and backend uploadTheme endpoint with permission & CSRF checks delegating to ThemeManager; exposes themeCsrfToken and activeTheme to the UI.
Template resolution & integration
phpmyfaq/src/phpMyFAQ/Twig/TwigWrapper.php, phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php, phpmyfaq/src/services.php
TwigWrapper now accepts/resolves an optional templateSetName and falls back to default; AbstractController passes configured templateSet; ThemeManager registered as a service.
Multi-tenant client changes
phpmyfaq/src/phpMyFAQ/Instance/Client.php
Adds clientTemplateSet and setClientTemplateSet(); copyTemplateFolder() gains copyTemplateFiles flag (default true) to optionally skip physical copying while updating layout.templateSet; tenant provisioning and seeding write templateSet accordingly.
Tests
tests/phpMyFAQ/Controller/Administration/Api/ConfigurationTabControllerTest.php, tests/phpMyFAQ/Instance/ClientTest.php, tests/phpMyFAQ/Template/ThemeManagerTest.php, tests/phpMyFAQ/Twig/TwigWrapperTest.php
New/updated tests: uploadTheme auth requirement; client template-set propagation and no-copy behavior; comprehensive ThemeManager tests using InMemoryStorage (upload, validations, activation); TwigWrapper fallback tests.
Docs & Changelog
docs/development.md, CHANGELOG.md
Documents dynamic template-set loading, ThemeManager upload/activation flow and admin UI steps/security; adds changelog entry for theme management capability.

Sequence Diagram

sequenceDiagram
    actor Admin as Admin User
    participant UI as Admin UI
    participant API as Config API
    participant TM as ThemeManager
    participant Storage as Storage
    participant Config as Configuration
    participant TW as TwigWrapper

    Admin->>UI: submit theme name + ZIP
    UI->>API: POST /api/configuration/themes/upload (FormData)
    API->>API: verify permissions & CSRF
    API->>TM: uploadTheme(themeName, archivePath)
    TM->>TM: validate name, ZIP, require index.twig, enforce allowed files
    TM->>Storage: write validated theme files
    TM->>Config: persist layout.templateSet (activation)
    API->>UI: return JSON success (activeTheme)
    UI->>TW: reinitialize/load templates with new templateSet
    TW->>Storage: load templates (falls back to default if missing)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

Poem

🐰 I zipped my hops and tucked them tight,
I checked each twig by lantern light,
I hopped to storage, left a breadcrumb trail,
Admin clicks upload — the new theme sets sail,
A rabbit grins as the FAQ gets bright.

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding a theme manager that enables theme uploads, validation, and activation throughout the codebase.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/improved-template-system

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
phpmyfaq/src/phpMyFAQ/Template/ThemeManager.php (3)

194-197: SVG files in the allowlist can carry embedded JavaScript (XSS vector).

SVG is allowed by isAllowedFileExtension, but SVG files can contain <script> tags and event handlers. If these theme assets are ever served with Content-Type: image/svg+xml without sanitization, an admin-uploaded theme could introduce stored XSS. Consider either removing svg from the allowlist or sanitizing SVG contents during upload (stripping script elements/event attributes).


161-185: normalizeArchivePath only strips the root folder when it matches $themeName exactly.

If an admin creates a ZIP with a differently-named root folder (e.g., my-theme-v2/index.twig but uploads as theme name my-theme), the prefix won't be stripped and files will be stored under themes/my-theme/my-theme-v2/index.twig. The index.twig check on line 79 would then fail, causing a confusing "missing index.twig" error. This is safe but could be a UX pitfall — consider documenting this requirement or stripping any single common root folder regardless of its name.


180-182: str_contains($rebuiltPath, '..') is overly broad — rejects legitimate filenames like file..name.twig.

While this errs on the safe side, a more precise check would target actual path traversal segments:

♻️ More precise traversal check
-        if (str_contains($rebuiltPath, '..')) {
+        if (preg_match('#(?:^|/)\.\.(?:/|$)#', $rebuiltPath)) {

This only matches .. as a standalone path segment rather than as a substring of a filename.

tests/phpMyFAQ/Template/ThemeManagerTest.php (2)

186-237: Consider moving InMemoryStorage to its own file.

Having two classes in one file works but deviates from PER CS 3.0 ("each class must be in a file by itself"). If other test suites need a test double for StorageInterface, this helper would need to be duplicated. Moving it to e.g. tests/phpMyFAQ/Storage/InMemoryStorage.php would improve reusability and compliance.

As per coding guidelines, Use PER Coding Style 3.0 for PHP code.


15-158: Missing test for path traversal rejection.

The normalizeArchivePath method throws on .. segments, but there's no test exercising this. A test uploading an archive with a ../escape.twig entry would strengthen coverage of this security-critical path.

💡 Suggested test
public function testUploadThemeRejectsPathTraversal(): void
{
    if (!class_exists(ZipArchive::class)) {
        $this->markTestSkipped('ZipArchive is not available in this environment.');
    }

    $archivePath = $this->createThemeArchive([
        'tenant-theme/index.twig' => '<h1>Hello</h1>',
        'tenant-theme/../etc/passwd' => 'malicious',
    ]);

    try {
        $configuration = $this->createStub(Configuration::class);
        $storage = new InMemoryStorage();
        $manager = new ThemeManager($configuration, $storage, 'themes');

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('invalid relative paths');

        $manager->uploadTheme('tenant-theme', $archivePath);
    } finally {
        `@unlink`($archivePath);
    }
}

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
phpmyfaq/src/phpMyFAQ/Instance/Client.php (1)

477-490: ⚠️ Potential issue | 🟡 Minor

Inconsistent escaping: clientUrl on line 482 is not escaped, but clientTemplateSet on line 489 is.

Pre-existing issue, but now visible by contrast: Line 482 interpolates $this->clientUrl directly into SQL without escape(), while the new template-set query on line 489 correctly calls $this->configuration->getDb()->escape(...). Consider escaping clientUrl as well for consistency.

Proposed fix
             $this->configuration
                 ->getDb()
                 ->query(sprintf(
                     "UPDATE %sfaqconfig SET config_value = '%s' WHERE config_name = 'main.referenceURL'",
                     $prefix,
-                    $this->clientUrl,
+                    $this->configuration->getDb()->escape($this->clientUrl),
                 ));
🤖 Fix all issues with AI agents
In `@docs/development.md`:
- Around line 499-541: The "9.2.3 Dynamic template set and theme upload" block
is in the wrong place and conflicts with duplicate numbering; move the entire
section (the header "9.2.3 Dynamic template set and theme upload" plus its
content) to follow the existing "9.2.2" content for Templating (the block around
the current "9.2.2 Admin backend templates"), then rename the existing duplicate
header "9.2.2 Admin backend templates" to "9.2.3" and rename the moved "9.2.3
Dynamic template set and theme upload" to "9.2.4", updating any internal
cross-references or numbering in the document accordingly so numbering is
sequential under section 9.2.

In `@phpmyfaq/assets/templates/admin/configuration/themes.upload.twig`:
- Line 7: Fix the grammatical error in the help text inside the
themes.upload.twig template: find the sentence that reads "ZIP must include
<code>index.twig</code>. Select the uploaded theme below using and save the
configuration." and replace it with a corrected sentence such as "ZIP must
include <code>index.twig</code>. Select the uploaded theme below and save the
configuration." ensuring the change is made in the template string rendered by
themes.upload.twig.

In `@phpmyfaq/src/phpMyFAQ/Template/ThemeManager.php`:
- Around line 55-97: The loop in import logic currently writes each entry via
$this->storage->put() (see themeStoragePath, normalizeArchivePath,
assertAllowedFileExtension) before full validation, so failures (missing
index.twig or disallowed extension) leave orphaned files; change the flow to
first validate and collect entries (e.g., build an array of
[$normalizedEntryPath => $contents] while tracking containsIndexTemplate and
uploadedFiles) and only call $this->storage->put() after all validation passes,
or implement a rollback: track stored storage paths during the loop and on any
exception delete them via $this->storage->delete() (ensure cleanup runs in the
finally/catch surrounding zip processing) so partial uploads are removed.

In `@tests/phpMyFAQ/Template/ThemeManagerTest.php`:
- Around line 113-122: The test named testUploadThemeRejectsInvalidThemeName is
misleading because it calls ThemeManager::activateTheme('../invalid') instead of
testing ThemeManager::uploadTheme; either rename the test to
testActivateThemeRejectsInvalidThemeName to match the exercised code (update the
method name and any references), or add a new test that calls
$manager->uploadTheme(...) with an invalid name to specifically cover
uploadTheme validation while keeping the existing activateTheme test as-is.
🧹 Nitpick comments (7)
phpmyfaq/src/phpMyFAQ/Template/ThemeManager.php (2)

93-95: Unreachable code: the $uploadedFiles === 0 check can never be true.

If $containsIndexTemplate is true (i.e., line 89 didn't throw), then at least index.twig was stored, meaning $uploadedFiles >= 1. This block is dead code.


142-166: Path traversal check using str_contains($rebuiltPath, '..') may produce false positives.

A legitimate filename like something..else.twig would be rejected. A more precise check would verify individual path segments:

More precise path traversal check
-        if (str_contains($rebuiltPath, '..')) {
+        if (in_array('..', $pathParts, true)) {
             throw new RuntimeException('Theme archive contains invalid relative paths.');
         }

This checks the $pathParts array (already computed above) for a literal .. segment rather than matching a substring anywhere in the path.

phpmyfaq/src/phpMyFAQ/Instance/Client.php (1)

63-71: setClientTemplateSet does not validate the theme name, unlike ThemeManager::assertValidThemeName.

ThemeManager enforces ^[A-Za-z0-9_-]{2,64}$ for theme names, but setClientTemplateSet only trims and defaults to 'default' for empty strings. Since clientTemplateSet is interpolated into SQL queries (even though it's escaped), applying the same validation here would add a defense-in-depth layer and keep the two code paths consistent.

Add theme name validation
 public function setClientTemplateSet(string $templateSet): void
 {
     $templateSet = trim($templateSet);
     if ($templateSet === '') {
         $templateSet = 'default';
     }

+    if (!preg_match('/^[A-Za-z0-9_-]{2,64}$/', $templateSet)) {
+        throw new \InvalidArgumentException('Invalid template set name.');
+    }
+
     $this->clientTemplateSet = $templateSet;
 }
tests/phpMyFAQ/Instance/ClientTest.php (1)

63-77: Consider asserting the actual template set value in the captured query.

The test calls setClientTemplateSet('tenant-theme') but only checks the query contains the config key name, not the value. This means the test would pass even if the wrong value was written.

Stronger assertion
-        $this->assertTrue($this->queryContains($queries, "WHERE config_name = 'layout.templateSet'"));
+        $this->assertTrue($this->queryContains($queries, "config_value = 'tenant-theme'"));
+        $this->assertTrue($this->queryContains($queries, "config_name = 'layout.templateSet'"));
tests/phpMyFAQ/Template/ThemeManagerTest.php (1)

149-200: InMemoryStorage test double is well implemented.

Clean and minimal. Consider extracting it to a shared test utilities namespace if other test classes need a similar fake storage.

phpmyfaq/admin/assets/src/configuration/configuration.ts (1)

275-293: Potential duplicate listeners if handleThemes is called without DOM replacement.

Currently this is safe because handleConfigurationTab('#layout') (line 285) replaces the DOM before handleThemes() re-attaches the listener. However, the recursive self-call on line 287 is fragile—if the refresh flow changes to not replace the DOM, duplicate submit handlers will stack up. Consider either using { once: true } on the listener or removing the old listener before attaching.

♻️ Safer alternative: avoid recursive re-wiring
 export const handleThemes = async (): Promise<void> => {
   const uploadForm = document.getElementById('theme-upload-form') as HTMLFormElement | null;
 
   if (uploadForm) {
     uploadForm.addEventListener('submit', async (event: Event): Promise<void> => {
       event.preventDefault();
 
       const response = (await uploadThemeArchive(new FormData(uploadForm))) as unknown as Response;
       if (typeof response.success === 'string') {
         pushNotification(response.success);
         await handleConfigurationTab('#layout');
         await handleTemplates();
+        await handleThemes();
-        await handleThemes();
       } else {
         pushErrorNotification(response.error || 'Theme upload failed.');
       }
-    });
+    }, { once: true });
   }
 };

Using { once: true } ensures the listener auto-removes after firing, so the recursive handleThemes() call safely re-attaches a fresh one-shot listener without risk of stacking.

phpmyfaq/src/phpMyFAQ/Twig/TwigWrapper.php (1)

49-59: Constructor sets global static state as a side effect.

Every TwigWrapper instantiation mutates self::$templateSetName (line 56). This is likely intentional for the current architecture, but be aware that constructing a TwigWrapper in tests or transient contexts will change the template set globally for all subsequent code relying on getTemplateSetName().

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Template/ThemeManager.php`:
- Around line 67-72: Skip ZIP directory entries before calling
normalizeArchivePath to avoid stripping trailing slashes and triggering
assertAllowedFileExtension on directory names: check the raw $entryName for a
trailing '/' (or empty) and continue early in the loop (before calling
normalizeArchivePath), so functions normalizeArchivePath and
assertAllowedFileExtension only receive actual file paths; update the loop
around normalizeArchivePath/assertAllowedFileExtension in ThemeManager to
perform this pre-normalization directory check.
🧹 Nitpick comments (1)
phpmyfaq/src/phpMyFAQ/Template/ThemeManager.php (1)

147-171: Path traversal check runs after prefix stripping — ordering is correct but the .. check is overly broad.

The str_contains($rebuiltPath, '..') check on line 166 will reject legitimate filenames containing .. as a substring (e.g., my..file.twig). While uncommon, a more precise check would inspect individual path segments:

♻️ More precise traversal check
-        if (str_contains($rebuiltPath, '..')) {
-            throw new RuntimeException('Theme archive contains invalid relative paths.');
-        }
+        foreach ($pathParts as $part) {
+            if ($part === '..') {
+                throw new RuntimeException('Theme archive contains invalid relative paths.');
+            }
+        }

Note: move this check before the implode on line 161, operating on $pathParts directly.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Template/ThemeManager.php`:
- Around line 105-109: The activateTheme method in ThemeManager sets the theme
name without confirming the theme actually exists, so update
activateTheme(string $themeName) to first call assertValidThemeName($themeName)
and then verify the theme storage path contains the required template (e.g.,
check for existence of "index.twig" under the theme directory via the
ThemeManager's storage access method or filesystem helper) before calling
$this->configuration->set('layout.templateSet', $themeName); if the file is
missing, throw or return false (consistent with the method signature) and do not
update configuration.
🧹 Nitpick comments (2)
phpmyfaq/src/phpMyFAQ/Template/ThemeManager.php (2)

166-168: Path traversal check using str_contains('..') is slightly overly broad.

This would also reject legitimate filenames containing consecutive dots (e.g., backup..old.twig). A more precise check would inspect individual path segments:

if (in_array('..', explode('/', $rebuiltPath), true)) {

This is unlikely to matter in practice for theme files, so flagging as optional.


152-159: Root folder flattening only triggers when the archive's root directory matches $themeName exactly.

This means if a ZIP has a root folder named differently (e.g., mytheme-main/), the uploaded files will be nested under that extra directory, potentially preventing index.twig from being found at the expected path. This could silently produce a broken theme that appears to upload successfully but fails at activation.

Consider making the flattening more resilient — for example, stripping any single common root directory regardless of its name when the archive has one.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@phpmyfaq/src/phpMyFAQ/Template/ThemeManager.php`:
- Around line 120-123: activateDefaultTheme() currently sets
configuration->set('layout.templateSet','default') without verifying the theme
exists; update it to mirror activateTheme() by checking storage for the default
theme's index.twig (or whatever presence check activateTheme() uses) before
calling configuration->set('layout.templateSet','default'), and return false if
the default theme is missing or invalid so rendering isn't broken; reference
activateDefaultTheme(), activateTheme(), index.twig and the configuration->set
call when making the change.
🧹 Nitpick comments (1)
phpmyfaq/src/phpMyFAQ/Template/ThemeManager.php (1)

156-180: Overly broad path-traversal check rejects legitimate filenames containing ...

str_contains($rebuiltPath, '..') on line 175 will reject files with .. anywhere in the path, including legitimate filenames like some..file.twig or directories like a..b/style.css. Consider checking for actual traversal sequences instead.

♻️ Suggested improvement
-        if (str_contains($rebuiltPath, '..')) {
+        if (in_array('..', $pathParts, true)) {
             throw new RuntimeException('Theme archive contains invalid relative paths.');
         }

This checks for .. as a discrete path segment rather than as a substring, which is both more precise and still secure. Note that $pathParts is already split by / and filtered for empty strings, so this catches actual traversal attempts.

@thorsten thorsten merged commit 7a8512a into main Feb 14, 2026
10 checks passed
@thorsten thorsten deleted the feat/improved-template-system branch February 14, 2026 16:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant