Skip to content
Open
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
27 changes: 26 additions & 1 deletion docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The plugin system is based on the Symfony Dependency Injection component.
Plugins are installed in the `content/plugins` directory of your phpMyFAQ installation.
The plugin directory should contain a subdirectory for each plugin, e.g. `content/plugins/HelloWorld`.
The plugin directory should contain a `HelloWorldPlugin.php` file that implements the `PluginInterface` interface.
The plugin must then be activated in the backend and can be deactivated again at any time without a complete uninstallation.
If you want to remove a plugin, you can delete the plugin in the plugin directory.

## 9.2 Configuration
Expand All @@ -17,7 +18,7 @@ Plugins can have configuration options, implemented via the `PluginConfiguration
Configuration options can be defined in the plugin configuration class with Constructor Property Promotion by adding
public properties. There are no other options to configure plugins at this time.

### 9.3.1 Example configuration class
### 9.2.1 Example configuration class

```php
class MyPluginConfiguration implements PluginConfigurationInterface
Expand All @@ -29,6 +30,7 @@ class MyPluginConfiguration implements PluginConfigurationInterface
}
}
```
These predefined values in the file form the basis for the configuration options in the backend plugin overview. All future changes to these values in the backend plugin settings will be saved in the database.

## 9.3 Plugin development

Expand Down Expand Up @@ -79,6 +81,28 @@ class MyPlugin implements PluginInterface
return '0.2.0';
}

public function getDescription(): string
{
return 'A simple Hello World plugin';
}

public function getAuthor(): string
{
return 'phpMyFAQ Team';
}

// Extended description of the plugin that will be displayed in the plugin settings
public function getAdvDescription(): string
{
return 'A simple Hello World plugin that demonstrates event handling in phpMyFAQ and with Configuration options.';
}

// Implementation of the plugin that will be displayed in the plugin settings
public function getImplementation(): string
{
return '{{ phpMyFAQPlugin(\'hello.world\', \'Hello, World!\') | raw }} oder {{ phpMyFAQPlugin(\'user.login\', \'John Doe\') | raw }}';
}

public function getDependencies(): array
{
return [];
Expand Down Expand Up @@ -139,6 +163,7 @@ class MyPlugin implements PluginInterface
{{ phpMyFAQPlugin('user.login', 'John Doe') | raw }}
</div>
```
Note: If the fields "Extended Description" and "Implementation" are defined in the plugin, you will find explanations on how to integrate the plugin in the plugin's configuration settings.

## 9.6 Stylesheets

Expand Down
1 change: 1 addition & 0 deletions phpmyfaq/admin/assets/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export * from './markdown';
export * from './media-browser';
export * from './news';
export * from './opensearch';
export * from './plugins';
export * from './question';
export * from './statistics';
export * from './stop-words';
Expand Down
77 changes: 77 additions & 0 deletions phpmyfaq/admin/assets/src/api/plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Plugin API calls
*
* This Source Code Form is subject to the terms of the Mozilla Public License,
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
* obtain one at https://mozilla.org/MPL/2.0/.
*
Copy link
Owner

Choose a reason for hiding this comment

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

File header data is not correct

* @package phpMyFAQ
* @author Thorsten Rinne
* @copyright 2025-2026 phpMyFAQ Team
* @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
* @link https://www.phpmyfaq.de
* @since 2025-01-07
*/

import { Response } from '../interfaces';

/**
* Toggle plugin status
*
* @param name
* @param active
* @param csrfToken
*/
export const togglePluginStatus = async (name: string, active: boolean, csrfToken: string): Promise<Response> => {
const response = await fetch('api/plugin/toggle', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({
name,
active,
Copy link
Owner

Choose a reason for hiding this comment

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

CSRF protection is missing

}),
});

if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP error! status: ${response.status} ${response.statusText}`);
}

return (await response.json()) as Response;
};

/**
* Save plugin configuration
*
* @param name
* @param config
* @param csrfToken
*/
export const savePluginConfig = async (
name: string,
config: Record<string, any>,
csrfToken: string
): Promise<Response> => {
const response = await fetch('api/plugin/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({
name,
config,
csrf: csrfToken, // Also including in body as requested for backend validation
}),
});

if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP error! status: ${response.status} ${response.statusText}`);
}

return (await response.json()) as Response;
};
1 change: 1 addition & 0 deletions phpmyfaq/admin/assets/src/configuration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export * from './elasticsearch';
export * from './forms';
export * from './instance';
export * from './opensearch';
export * from './plugins';
export * from './stopwords';
export * from './upgrade';
227 changes: 227 additions & 0 deletions phpmyfaq/admin/assets/src/configuration/plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/**
* Plugin management logic for phpMyFAQ admin backend
*
* This Source Code Form is subject to the terms of the Mozilla Public License,
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
* obtain one at https://mozilla.org/MPL/2.0/.
*
* @package phpMyFAQ
* @author Thorsten Rinne
* @copyright 2025-2026 phpMyFAQ Team
* @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
* @link https://www.phpmyfaq.de
* @since 2025-01-07
*/

import { togglePluginStatus, savePluginConfig } from '../api';
import { addElement, pushNotification, pushErrorNotification, TranslationService } from '../../../../assets/src/utils';

/**
* Handles plugin status toggling and configuration modal
*/
export const handlePlugins = async (): Promise<void> => {
const Translator = new TranslationService();
await Translator.loadTranslations(document.documentElement.lang);

const getCsrfToken = () => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';

const toggleCheckboxes = document.querySelectorAll<HTMLInputElement>('.plugin-toggle');
toggleCheckboxes.forEach((checkbox) => {
checkbox.addEventListener('change', async (event) => {
const target = event.target as HTMLInputElement;
const row = target.closest('tr');
const pluginName = row ? row.getAttribute('data-plugin-name') : null;
const active = target.checked;
const csrfToken = getCsrfToken();

if (!pluginName) {
return;
}

try {
const result = await togglePluginStatus(pluginName, active, csrfToken);

if (!result.success) {
target.checked = !active; // Revert
pushErrorNotification(
Translator.translate('msgPluginStatusError') + ' ' + (result.message || Translator.translate('msgUnknownError')),
);
} else {
pushNotification(Translator.translate('msgPluginStatusSuccess'));
}
} catch (error: any) {
target.checked = !active; // Revert
console.error('Error toggling plugin:', error);
pushErrorNotification(error.message || Translator.translate('msgUnknownError'));
}
});
});

// Configuration Modal
const pluginConfigModal = document.getElementById('pluginConfigModal');
if (pluginConfigModal) {
pluginConfigModal.addEventListener('show.bs.modal', (event: any) => {
const button = event.relatedTarget as HTMLElement;
const pluginName = button.getAttribute('data-plugin-name');
const pluginDescription = button.getAttribute('data-plugin-description');
const pluginImplementation = button.getAttribute('data-plugin-implementation');
const configJson = button.getAttribute('data-plugin-config');

const modalTitle = pluginConfigModal.querySelector('.modal-title');
const nameInput = pluginConfigModal.querySelector<HTMLInputElement>('#configPluginName');
const container = pluginConfigModal.querySelector('#configFieldsContainer');
const descText = pluginConfigModal.querySelector('#pluginDescriptionText');
const implContainer = pluginConfigModal.querySelector('#pluginImplementationContainer');
const implCode = pluginConfigModal.querySelector('#pluginImplementationCode');
const noConfigMsg = pluginConfigModal.querySelector('#pluginNoConfigMsg');
const saveBtn = document.getElementById('savePluginConfigBtn');

if (modalTitle && pluginName) {
modalTitle.textContent = Translator.translate('msgConfig') + ': ' + pluginName;
}
if (nameInput && pluginName) {
nameInput.value = pluginName;
}
if (descText) {
descText.textContent = pluginDescription || '-';
}

if (implContainer && implCode) {
if (pluginImplementation) {
implCode.textContent = pluginImplementation;
implContainer.classList.remove('d-none');
} else {
implContainer.classList.add('d-none');
}
}

if (container) {
container.innerHTML = ''; // Clear previous fields
}
if (noConfigMsg) noConfigMsg.classList.add('d-none');
if (saveBtn) saveBtn.style.display = 'none';

let hasConfig = false;

if (configJson) {
try {
const configData = JSON.parse(configJson);

if (configData && typeof configData === 'object' && Object.keys(configData).length > 0) {
hasConfig = true;
if (saveBtn) saveBtn.style.display = 'block';

Object.keys(configData).forEach((key) => {
const value = configData[key];
let input: HTMLElement;

if (typeof value === 'boolean') {
input = addElement('div', { className: 'form-check form-switch mb-3' }, [
addElement('input', {
type: 'checkbox',
className: 'form-check-input',
checked: value,
value: '1',
id: 'config_' + key,
name: 'config[' + key + ']',
}),
addElement('label', {
className: 'form-check-label',
textContent: key,
htmlFor: 'config_' + key,
}),
]);
} else {
const type = (typeof value === 'number' || !isNaN(Number(value)) && String(value).trim() !== '') ? 'number' :
(key.toLowerCase().includes('email')) ? 'email' :
(key.toLowerCase().includes('date')) ? 'date' : 'text';

const props: Record<string, any> = {
className: 'form-control',
value: String(value),
id: 'config_' + key,
name: 'config[' + key + ']',
};

if (String(value).length > 50 && type === 'text') {
input = addElement('div', { className: 'mb-3' }, [
addElement('label', { className: 'form-label', textContent: key, htmlFor: 'config_' + key }),
addElement('textarea', { ...props, rows: 3 }),
]);
} else {
input = addElement('div', { className: 'mb-3' }, [
addElement('label', { className: 'form-label', textContent: key, htmlFor: 'config_' + key }),
addElement('input', { ...props, type }),
]);
}
}

if (container) {
container.appendChild(input);
}
});
}
} catch (e) {
console.error('Error parsing config:', e);
}
}

if (!hasConfig && noConfigMsg) {
noConfigMsg.classList.remove('d-none');
}
});
}

// Save Configuration
const saveBtn = document.getElementById('savePluginConfigBtn');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
const form = document.getElementById('pluginConfigForm') as HTMLFormElement;
const container = document.getElementById('configFieldsContainer');
if (!form || !container) return;

const nameInput = form.querySelector<HTMLInputElement>('input[name="name"]');
const pluginName = nameInput?.value;
const csrfToken = getCsrfToken();
const configData: Record<string, any> = {};

const inputs = container.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(
'input, select, textarea',
);
inputs.forEach((input) => {
const nameAttr = input.getAttribute('name');
if (nameAttr && nameAttr.startsWith('config[')) {
const key = nameAttr.substring(7, nameAttr.length - 1);
if (input instanceof HTMLInputElement && input.type === 'checkbox') {
configData[key] = input.checked;
} else if (input instanceof HTMLInputElement && input.type === 'number') {
configData[key] = input.value.includes('.') ? parseFloat(input.value) : parseInt(input.value, 10);
if (isNaN(configData[key])) {
configData[key] = 0;
}
} else {
configData[key] = input.value;
}
}
});

if (!pluginName) return;

try {
const result = await savePluginConfig(pluginName, configData, csrfToken);

if (result.success) {
pushNotification(Translator.translate('msgPluginConfigSuccess'));
window.location.reload();
} else {
pushErrorNotification(
Translator.translate('msgPluginConfigError') + ' ' + (result.message || Translator.translate('msgUnknownError')),
);
}
} catch (error: any) {
console.error('Error saving config:', error);
pushErrorNotification(error.message || Translator.translate('msgUnknownError'));
}
});
}
};
Loading
Loading