Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This is a log of major user-visible changes in each phpMyFAQ release.
- added support for Flesch readability tests (Thorsten)
- added storage abstraction layer with support for local filesystem, and Amazon S3 (Thorsten)
- added support for SendGrid, AWS SES, and Mailgun (Thorsten)
- added theme manager with support for multiple themes and theme switching (Thorsten)
- added experimental support for API key authentication via OAuth2 (Thorsten)
- added experimental per-tenant quota enforcement, and API request rate limits (Thorsten)
- improved audit and activity log with comprehensive security event tracking (Thorsten)
Expand Down
45 changes: 44 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,55 @@ You can then use the `dump` function in your templates.

For more detailed information, visit the [Twig documentation](https://twig.symfony.com/doc/).

### 9.2.2 Admin backend templates
### 9.2.3 Admin backend templates

The admin backend templates are located in the **assets/templates/admin** directory.
Usually, you don't need to modify these templates, but if you want to, you can do so.
Please be aware that changes to the admin backend templates can break the functionality of phpMyFAQ.

### 9.2.4 Dynamic template set and theme upload

phpMyFAQ v4.2 introduced dynamic template set loading and a storage-backed `ThemeManager`.

- Runtime template set is read from configuration key `layout.templateSet`.
- `TwigWrapper` now validates the requested set and falls back to `default` if the
configured set is invalid or missing on disk.
- Tenant provisioning can skip physical template copies and only persist the
template reference in `faqconfig` (`layout.templateSet`).

Theme uploads use `phpMyFAQ\Template\ThemeManager`:

- Input format: ZIP archive.
- Required file: `index.twig` (theme root or `<themeName>/index.twig`).
- Allowed file extensions:
`.twig`, `.css`, `.js`, `.json`, `.png`, `.jpg`, `.jpeg`, `.svg`, `.webp`, `.gif`,
`.woff`, `.woff2`, `.ttf`, `.otf`.
- Upload target: `StorageInterface` at `<themeRootPath>/<themeName>/...`
(default root path: `themes`).
- Activation: `layout.templateSet` is updated via `ThemeManager::activateTheme()`.

Example usage:

```php
use phpMyFAQ\Template\ThemeManager;

$themeManager = new ThemeManager($configuration, $storage, 'themes');
$themeManager->uploadTheme('tenant-theme', '/tmp/tenant-theme.zip');
$themeManager->activateTheme('tenant-theme');
```

Admin UI:

- Open `/admin/configuration`
- Use the `layout` tab
- Upload ZIP in the `layout` tab
- Select the template in `layout.templateSet` and save configuration

Security checks:

- Requires `CONFIGURATION_EDIT` permission
- CSRF token (`theme-manager`) is required for upload/activation

## 9.3 Themes

The default CSS theme is located in the **assets/templates/default** directory and is stored in the file **theme.css**.
Expand Down
7 changes: 7 additions & 0 deletions phpmyfaq/admin/assets/src/api/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,10 @@ export const saveConfiguration = async (data: FormData): Promise<Response> => {
body: data,
})) as Response;
};

export const uploadThemeArchive = async (data: FormData): Promise<Response> => {
return (await fetchJson('api/configuration/themes/upload', {
method: 'POST',
body: data,
})) as Response;
};
22 changes: 22 additions & 0 deletions phpmyfaq/admin/assets/src/configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
fetchTemplates,
fetchTranslations,
fetchTranslationProvider,
uploadThemeArchive,
saveConfiguration,
} from '../api';
import { handleWebPush } from './webpush';
Expand All @@ -51,6 +52,7 @@ export const handleConfiguration = async (): Promise<void> => {
break;
case '#layout':
await handleTemplates();
await handleThemes();
break;
case '#records':
await handleFaqsSortingKeys();
Expand Down Expand Up @@ -270,6 +272,26 @@ export const handleMailProvider = async (): Promise<void> => {
}
};

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();
} else {
pushErrorNotification(response.error || 'Theme upload failed.');
}
});
}
};

export const handleConfigurationTab = async (target: string): Promise<void> => {
const languageElement = document.getElementById('pmf-language') as HTMLInputElement;
if (!languageElement) {
Expand Down
7 changes: 7 additions & 0 deletions phpmyfaq/assets/templates/admin/configuration/tab-list.twig
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@
</div>
{% endfor %}

{% if mode == 'layout' %}
{% include '@admin/configuration/themes.upload.twig' with {
csrfToken: themeCsrfToken,
activeTheme: activeTheme
} only %}
{% endif %}

<script>
try {
const generateUUID = () => {
Expand Down
31 changes: 31 additions & 0 deletions phpmyfaq/assets/templates/admin/configuration/themes.upload.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<div class="row mb-3">
<div class="col-lg-12">
<div class="card shadow-sm">
<div class="card-body">
<h3 class="h5 mb-3">Upload Theme ZIP</h3>
<p class="text-muted small mb-3">
ZIP must include <code>index.twig</code>. Select the uploaded theme below and save the configuration.
</p>
<form id="theme-upload-form" method="post" action="#" enctype="multipart/form-data">
<input type="hidden" name="pmf-csrf-token" value="{{ csrfToken }}">
<div class="row">
<div class="col-md-4 mb-3">
<label for="themeName" class="form-label">Theme Name</label>
<input type="text" class="form-control" id="themeName" name="themeName" placeholder="Theme Name">
</div>
<div class="col-md-6 mb-3">
<label for="themeArchive" class="form-label">ZIP File</label>
<input type="file" class="form-control" id="themeArchive" name="themeArchive" accept=".zip" required>
</div>
<div class="col-md-2 mb-3 d-flex align-items-end">
<button type="submit" class="btn btn-success w-100">
<i class="bi bi-upload" aria-hidden="true"></i>
Upload
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
6 changes: 5 additions & 1 deletion phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,11 @@ public function json(mixed $data, int $status = 200, array $headers = []): JsonR
*/
public function getTwigWrapper(): TwigWrapper
{
$twigWrapper = new TwigWrapper(PMF_ROOT_DIR . '/assets/templates');
$twigWrapper = new TwigWrapper(
PMF_ROOT_DIR . '/assets/templates',
false,
$this->configuration->getTemplateSet(),
);

foreach ($this->twigExtensions as $twigExtension) {
$twigWrapper->addExtension($twigExtension);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@
use phpMyFAQ\Helper\PermissionHelper;
use phpMyFAQ\Session\Token;
use phpMyFAQ\Strings;
use phpMyFAQ\Template\ThemeManager;
use phpMyFAQ\Translation;
use phpMyFAQ\Twig\TemplateException;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
Expand Down Expand Up @@ -77,9 +79,44 @@ public function list(Request $request): Response
'ssoSupport' => Request::createFromGlobals()->server->get(key: 'REMOTE_USER'),
'buttonTes',
],
'themeCsrfToken' => Token::getInstance($this->session)->getTokenString('theme-manager'),
'activeTheme' => (string) $this->configuration->get('layout.templateSet'),
]);
}

/**
* @throws \Exception
*/
#[Route(path: 'configuration/themes/upload', name: 'admin.api.configuration.themes.upload', methods: ['POST'])]
public function uploadTheme(Request $request): JsonResponse
{
$this->userHasPermission(PermissionType::CONFIGURATION_EDIT);

if (!$this->hasValidThemeCsrfToken($request)) {
return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED);
}

$file = $request->files->get('themeArchive');
if (!$file instanceof UploadedFile || !$file->isValid()) {
return $this->json(['error' => 'No valid ZIP file uploaded.'], Response::HTTP_BAD_REQUEST);
}

$themeName = trim((string) $request->request->get('themeName', ''));
if ($themeName === '') {
$themeName = pathinfo((string) $file->getClientOriginalName(), PATHINFO_FILENAME);
}

try {
$uploadedFiles = $this->themeManager()->uploadTheme($themeName, $file->getPathname());

return $this->json([
'success' => sprintf('Theme "%s" uploaded (%d files).', $themeName, $uploadedFiles),
], Response::HTTP_OK);
} catch (\RuntimeException $runtimeException) {
return $this->json(['error' => $runtimeException->getMessage()], Response::HTTP_BAD_REQUEST);
}
}

/**
* @throws \Exception
*/
Expand Down Expand Up @@ -472,4 +509,20 @@ private function convertToString(mixed $value): string

return 'unknown';
}

private function hasValidThemeCsrfToken(Request $request): bool
{
$csrfToken = (string) $request->request->get('pmf-csrf-token', '');
return Token::getInstance($this->session)->verifyToken('theme-manager', $csrfToken);
}

private function themeManager(): ThemeManager
{
$themeManager = $this->container->get(id: 'phpmyfaq.template.theme-manager');
if (!$themeManager instanceof ThemeManager) {
throw new BadRequestException('Theme manager service is not available.');
}

return $themeManager;
}
}
51 changes: 49 additions & 2 deletions phpmyfaq/src/phpMyFAQ/Instance/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ class Client extends Instance

private string $clientUrl;

private string $clientTemplateSet = 'default';

/**
* Constructor.
*/
Expand All @@ -58,6 +60,16 @@ public function setClientUrl(string $clientUrl): void
$this->clientUrl = $clientUrl;
}

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

$this->clientTemplateSet = $templateSet;
}

/**
* Sets the Filesystem.
*/
Expand Down Expand Up @@ -273,6 +285,17 @@ private function copyBaseDataToSchema(string $schema): void
$sourcePrefix,
);

$this->executeSchemaQuery(
sprintf(
"UPDATE %sfaqconfig SET config_value = '%s' WHERE config_name = 'layout.templateSet'",
$targetPrefix,
$this->configuration->getDb()->escape($this->clientTemplateSet),
),
'UPDATE faqconfig',
$targetPrefix,
$sourcePrefix,
);

$this->executeSchemaQuery(
sprintf('INSERT INTO %sfaqright SELECT * FROM %sfaqright', $targetPrefix, $sourcePrefix),
'INSERT faqright',
Expand Down Expand Up @@ -369,6 +392,13 @@ private function insertSeedRows(string $prefix, array $seedRows): void
$prefix,
$this->configuration->getDb()->escape($this->clientUrl),
));
$this->configuration
->getDb()
->query(sprintf(
"UPDATE %sfaqconfig SET config_value = '%s' WHERE config_name = 'layout.templateSet'",
$prefix,
$this->configuration->getDb()->escape($this->clientTemplateSet),
));

$this->insertRows($prefix . 'faqright', $seedRows['faqright'] ?? []);
$this->insertRows($prefix . 'faquser_right', $seedRows['faquser_right'] ?? []);
Expand Down Expand Up @@ -451,6 +481,13 @@ public function createClientTables(string $prefix): void
$prefix,
$this->clientUrl,
));
$this->configuration
->getDb()
->query(sprintf(
"UPDATE %sfaqconfig SET config_value = '%s' WHERE config_name = 'layout.templateSet'",
$prefix,
$this->configuration->getDb()->escape($this->clientTemplateSet),
));
$this->configuration
->getDb()
->query(sprintf(
Expand Down Expand Up @@ -495,10 +532,20 @@ public function copyConstantsFile(string $destination): bool
*
* @param string $destination Destination folder
* @param string $templateDir Template folder
* @param bool $copyTemplateFiles Set to false to only reference the template set in config
* @throws Exception
*/
public function copyTemplateFolder(string $destination, string $templateDir = 'default'): void
{
public function copyTemplateFolder(
string $destination,
string $templateDir = 'default',
bool $copyTemplateFiles = true,
): void {
$this->setClientTemplateSet($templateDir);

if (!$copyTemplateFiles) {
return;
}

$sourceTpl = $this->filesystem->getRootPath() . '/assets/templates/' . $templateDir;
$destTpl = $destination . '/assets/templates/';

Expand Down
Loading