From fdd837bdc1d36f9f005308b16e84aa41e8089c48 Mon Sep 17 00:00:00 2001 From: d0ubIeU Date: Wed, 7 Jan 2026 18:35:26 +0100 Subject: [PATCH 1/9] Feature: Extension of the plugin system to include configuration and activation options --- phpmyfaq/admin/assets/src/api/index.ts | 1 + phpmyfaq/admin/assets/src/api/plugins.ts | 58 ++++ .../admin/assets/src/configuration/index.ts | 1 + .../admin/assets/src/configuration/plugins.ts | 199 +++++++++++++ phpmyfaq/admin/assets/src/index.ts | 4 + .../admin/configuration/plugins.twig | 71 ++++- .../HelloWorld/HelloWorldConfiguration.php | 27 ++ .../plugins/HelloWorld/HelloWorldPlugin.php | 44 ++- .../HelloWorldPluginConfiguration.php | 31 ++ phpmyfaq/src/admin-routes.php | 10 + phpmyfaq/src/phpMyFAQ/Configuration.php | 2 +- .../Administration/Api/PluginController.php | 70 +++++ .../Administration/PluginController.php | 7 + phpmyfaq/src/phpMyFAQ/Database/Mysqli.php | 1 + phpmyfaq/src/phpMyFAQ/Database/PdoMysql.php | 1 + phpmyfaq/src/phpMyFAQ/Database/PdoPgsql.php | 1 + phpmyfaq/src/phpMyFAQ/Database/PdoSqlite.php | 1 + phpmyfaq/src/phpMyFAQ/Database/PdoSqlsrv.php | 1 + phpmyfaq/src/phpMyFAQ/Database/Pgsql.php | 1 + phpmyfaq/src/phpMyFAQ/Database/Sqlite3.php | 1 + phpmyfaq/src/phpMyFAQ/Database/Sqlsrv.php | 1 + .../src/phpMyFAQ/Instance/Database/Mysqli.php | 6 + .../phpMyFAQ/Instance/Database/PdoMysql.php | 6 + .../phpMyFAQ/Instance/Database/PdoPgsql.php | 5 + .../phpMyFAQ/Instance/Database/PdoSqlite.php | 5 + .../phpMyFAQ/Instance/Database/PdoSqlsrv.php | 5 + .../src/phpMyFAQ/Instance/Database/Pgsql.php | 5 + .../phpMyFAQ/Instance/Database/Sqlite3.php | 5 + .../src/phpMyFAQ/Instance/Database/Sqlsrv.php | 5 + .../src/phpMyFAQ/Plugin/PluginManager.php | 264 +++++++++++++++--- phpmyfaq/src/phpMyFAQ/Setup/Update.php | 56 ++++ .../Twig/Extensions/PluginTwigExtension.php | 2 +- phpmyfaq/src/services.php | 5 +- phpmyfaq/translations/language_de.php | 16 ++ phpmyfaq/translations/language_en.php | 16 ++ 35 files changed, 882 insertions(+), 52 deletions(-) create mode 100644 phpmyfaq/admin/assets/src/api/plugins.ts create mode 100644 phpmyfaq/admin/assets/src/configuration/plugins.ts create mode 100644 phpmyfaq/content/plugins/HelloWorld/HelloWorldConfiguration.php create mode 100644 phpmyfaq/content/plugins/HelloWorld/HelloWorldPluginConfiguration.php create mode 100644 phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/PluginController.php diff --git a/phpmyfaq/admin/assets/src/api/index.ts b/phpmyfaq/admin/assets/src/api/index.ts index d7e8270a1e..3daaa1da36 100644 --- a/phpmyfaq/admin/assets/src/api/index.ts +++ b/phpmyfaq/admin/assets/src/api/index.ts @@ -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'; diff --git a/phpmyfaq/admin/assets/src/api/plugins.ts b/phpmyfaq/admin/assets/src/api/plugins.ts new file mode 100644 index 0000000000..2172feca17 --- /dev/null +++ b/phpmyfaq/admin/assets/src/api/plugins.ts @@ -0,0 +1,58 @@ +/** + * 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/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2025-2026 phpMyFAQ Team + * @license http://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 + */ +export const togglePluginStatus = async (name: string, active: boolean): Promise => { + const response = await fetch('api/plugin/toggle', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name, + active, + }), + }); + + return (await response.json()) as Response; +}; + +/** + * Save plugin configuration + * + * @param name + * @param config + */ +export const savePluginConfig = async (name: string, config: Record): Promise => { + const response = await fetch('api/plugin/config', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name, + config, + }), + }); + + return (await response.json()) as Response; +}; diff --git a/phpmyfaq/admin/assets/src/configuration/index.ts b/phpmyfaq/admin/assets/src/configuration/index.ts index f3113ba55a..d1cdc81019 100644 --- a/phpmyfaq/admin/assets/src/configuration/index.ts +++ b/phpmyfaq/admin/assets/src/configuration/index.ts @@ -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'; diff --git a/phpmyfaq/admin/assets/src/configuration/plugins.ts b/phpmyfaq/admin/assets/src/configuration/plugins.ts new file mode 100644 index 0000000000..8919c29b24 --- /dev/null +++ b/phpmyfaq/admin/assets/src/configuration/plugins.ts @@ -0,0 +1,199 @@ +/** + * 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 http://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 { pushNotification, pushErrorNotification } from '../../../../assets/src/utils'; + +/** + * Handles plugin status toggling and configuration modal + */ +export const handlePlugins = (): void => { + const toggleCheckboxes = document.querySelectorAll('.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; + + if (!pluginName) { + return; + } + + try { + const result = await togglePluginStatus(pluginName, active); + + if (!result.success) { + target.checked = !active; // Revert + pushErrorNotification('Failed to update plugin status: ' + (result.message || 'Unknown error')); + } else { + pushNotification('Plugin status updated successfully.'); + } + } catch (error) { + target.checked = !active; // Revert + console.error('Error toggling plugin:', error); + pushErrorNotification('An error occurred while updating plugin status.'); + } + }); + }); + + // Configuration Modal + const configModalEl = document.getElementById('pluginConfigModal'); + if (configModalEl) { + configModalEl.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 = configModalEl.querySelector('.modal-title'); + const nameInput = configModalEl.querySelector('#configPluginName'); + const container = configModalEl.querySelector('#configFieldsContainer'); + const descText = configModalEl.querySelector('#pluginDescriptionText'); + const implContainer = configModalEl.querySelector('#pluginImplementationContainer'); + const implCode = configModalEl.querySelector('#pluginImplementationCode'); + const noConfigMsg = configModalEl.querySelector('#pluginNoConfigMsg'); + const saveBtn = document.getElementById('savePluginConfigBtn'); + + if (modalTitle && pluginName) { + modalTitle.textContent = 'Configuration: ' + 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]; + const div = document.createElement('div'); + div.className = 'mb-3'; + + const label = document.createElement('label'); + label.className = 'form-label'; + label.textContent = key; + label.htmlFor = 'config_' + key; + + let input: HTMLInputElement; + + if (typeof value === 'boolean') { + div.className = 'form-check form-switch mb-3'; + input = document.createElement('input'); + input.type = 'checkbox'; + input.className = 'form-check-input'; + input.checked = value; + input.value = '1'; + label.className = 'form-check-label'; + div.appendChild(input); + div.appendChild(label); + } else { + input = document.createElement('input'); + input.type = 'text'; + input.className = 'form-control'; + input.value = String(value); + div.appendChild(label); + div.appendChild(input); + } + + input.id = 'config_' + key; + input.setAttribute('name', 'config[' + key + ']'); + + if (container) { + container.appendChild(div); + } + }); + } + } 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('input[name="name"]'); + const pluginName = nameInput?.value; + const configData: Record = {}; + + const inputs = container.querySelectorAll( + '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 { + configData[key] = input.value; + } + } + }); + + if (!pluginName) return; + + try { + const result = await savePluginConfig(pluginName, configData); + + if (result.success) { + window.location.reload(); + } else { + pushErrorNotification('Failed to save configuration: ' + (result.message || 'Unknown error')); + } + } catch (error) { + console.error('Error saving config:', error); + pushErrorNotification('An error occurred while saving configuration.'); + } + }); + } +}; diff --git a/phpmyfaq/admin/assets/src/index.ts b/phpmyfaq/admin/assets/src/index.ts index eaf6ae4d75..43ad5e123c 100644 --- a/phpmyfaq/admin/assets/src/index.ts +++ b/phpmyfaq/admin/assets/src/index.ts @@ -35,6 +35,7 @@ import { handleFormEdit, handleFormTranslations, handleOpenSearch, + handlePlugins, } from './configuration'; import { handleAttachmentUploads, @@ -165,6 +166,9 @@ document.addEventListener('DOMContentLoaded', async (): Promise => { await handleElasticsearch(); await handleOpenSearch(); + // Configuration → Plugins + handlePlugins(); + // Import & Export → Import Records await handleUploadCSVForm(); diff --git a/phpmyfaq/assets/templates/admin/configuration/plugins.twig b/phpmyfaq/assets/templates/admin/configuration/plugins.twig index 554a50b254..e323e967e2 100644 --- a/phpmyfaq/assets/templates/admin/configuration/plugins.twig +++ b/phpmyfaq/assets/templates/admin/configuration/plugins.twig @@ -20,22 +20,91 @@ {{ 'msgPluginName' | translate }} {{ 'msgPluginVersion' | translate }} + {{ 'msgActive' | translate }} {{ 'msgPluginAuthor' | translate }} {{ 'msgPluginDescription' | translate }} + {{ 'msgActions' | translate }} {% for plugin in pluginList %} - + {{ plugin.name }} {{ plugin.version }} + +
+ + +
+ {{ plugin.author }} {{ plugin.description }} + + + {% endfor %} + + + + {% if incompatiblePlugins is not empty %} ``` +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 diff --git a/phpmyfaq/admin/assets/src/api/plugins.ts b/phpmyfaq/admin/assets/src/api/plugins.ts index c06481312b..1e69c31d73 100644 --- a/phpmyfaq/admin/assets/src/api/plugins.ts +++ b/phpmyfaq/admin/assets/src/api/plugins.ts @@ -6,9 +6,9 @@ * obtain one at https://mozilla.org/MPL/2.0/. * * @package phpMyFAQ - * @author Thorsten Rinne + * @author Thorsten Rinne * @copyright 2025-2026 phpMyFAQ Team - * @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 * @link https://www.phpmyfaq.de * @since 2025-01-07 */ diff --git a/phpmyfaq/admin/assets/src/configuration/plugins.ts b/phpmyfaq/admin/assets/src/configuration/plugins.ts index 717c0ffd06..a03c150ccb 100644 --- a/phpmyfaq/admin/assets/src/configuration/plugins.ts +++ b/phpmyfaq/admin/assets/src/configuration/plugins.ts @@ -6,20 +6,23 @@ * obtain one at https://mozilla.org/MPL/2.0/. * * @package phpMyFAQ - * @author Thorsten Rinne + * @author Thorsten Rinne * @copyright 2025-2026 phpMyFAQ Team - * @license http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @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 { pushNotification, pushErrorNotification } from '../../../../assets/src/utils'; +import { addElement, pushNotification, pushErrorNotification, TranslationService } from '../../../../assets/src/utils'; /** * Handles plugin status toggling and configuration modal */ -export const handlePlugins = (): void => { +export const handlePlugins = async (): Promise => { + const Translator = new TranslationService(); + await Translator.loadTranslations(document.documentElement.lang); + const getCsrfToken = () => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; const toggleCheckboxes = document.querySelectorAll('.plugin-toggle'); @@ -40,39 +43,41 @@ export const handlePlugins = (): void => { if (!result.success) { target.checked = !active; // Revert - pushErrorNotification('Failed to update plugin status: ' + (result.message || 'Unknown error')); + pushErrorNotification( + Translator.translate('msgPluginStatusError') + ' ' + (result.message || Translator.translate('msgUnknownError')), + ); } else { - pushNotification('Plugin status updated successfully.'); + pushNotification(Translator.translate('msgPluginStatusSuccess')); } } catch (error: any) { target.checked = !active; // Revert console.error('Error toggling plugin:', error); - pushErrorNotification(error.message || 'An error occurred while updating plugin status.'); + pushErrorNotification(error.message || Translator.translate('msgUnknownError')); } }); }); // Configuration Modal - const configModalEl = document.getElementById('pluginConfigModal'); - if (configModalEl) { - configModalEl.addEventListener('show.bs.modal', (event: any) => { + 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 = configModalEl.querySelector('.modal-title'); - const nameInput = configModalEl.querySelector('#configPluginName'); - const container = configModalEl.querySelector('#configFieldsContainer'); - const descText = configModalEl.querySelector('#pluginDescriptionText'); - const implContainer = configModalEl.querySelector('#pluginImplementationContainer'); - const implCode = configModalEl.querySelector('#pluginImplementationCode'); - const noConfigMsg = configModalEl.querySelector('#pluginNoConfigMsg'); + const modalTitle = pluginConfigModal.querySelector('.modal-title'); + const nameInput = pluginConfigModal.querySelector('#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 = 'Configuration: ' + pluginName; + modalTitle.textContent = Translator.translate('msgConfig') + ': ' + pluginName; } if (nameInput && pluginName) { nameInput.value = pluginName; @@ -108,69 +113,51 @@ export const handlePlugins = (): void => { Object.keys(configData).forEach((key) => { const value = configData[key]; - const div = document.createElement('div'); - div.className = 'mb-3'; - - const label = document.createElement('label'); - label.className = 'form-label'; - label.textContent = key; - label.htmlFor = 'config_' + key; - - let input: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + let input: HTMLElement; if (typeof value === 'boolean') { - div.className = 'form-check form-switch mb-3'; - const checkInput = document.createElement('input'); - checkInput.type = 'checkbox'; - checkInput.className = 'form-check-input'; - checkInput.checked = value; - checkInput.value = '1'; - label.className = 'form-check-label'; - div.appendChild(checkInput); - div.appendChild(label); - input = checkInput; - } else if (typeof value === 'number' || !isNaN(Number(value)) && String(value).trim() !== '') { - input = document.createElement('input'); - input.type = 'number'; - input.className = 'form-control'; - input.value = String(value); - div.appendChild(label); - div.appendChild(input); - } else if (key.toLowerCase().includes('email')) { - input = document.createElement('input'); - input.type = 'email'; - input.className = 'form-control'; - input.value = String(value); - div.appendChild(label); - div.appendChild(input); - } else if (key.toLowerCase().includes('date')) { - input = document.createElement('input'); - input.type = 'date'; - input.className = 'form-control'; - input.value = String(value); - div.appendChild(label); - div.appendChild(input); - } else if (String(value).length > 50) { - input = document.createElement('textarea'); - input.className = 'form-control'; - input.rows = 3; - input.value = String(value); - div.appendChild(label); - div.appendChild(input); + 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 { - input = document.createElement('input'); - input.type = 'text'; - input.className = 'form-control'; - input.value = String(value); - div.appendChild(label); - div.appendChild(input); + 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 = { + 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 }), + ]); + } } - input.id = 'config_' + key; - input.setAttribute('name', 'config[' + key + ']'); - if (container) { - container.appendChild(div); + container.appendChild(input); } }); } @@ -224,13 +211,16 @@ export const handlePlugins = (): void => { const result = await savePluginConfig(pluginName, configData, csrfToken); if (result.success) { + pushNotification(Translator.translate('msgPluginConfigSuccess')); window.location.reload(); } else { - pushErrorNotification('Failed to save configuration: ' + (result.message || 'Unknown error')); + pushErrorNotification( + Translator.translate('msgPluginConfigError') + ' ' + (result.message || Translator.translate('msgUnknownError')), + ); } } catch (error: any) { console.error('Error saving config:', error); - pushErrorNotification(error.message || 'An error occurred while saving configuration.'); + pushErrorNotification(error.message || Translator.translate('msgUnknownError')); } }); } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/PluginController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/PluginController.php index 14041a04cc..67a2129251 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/PluginController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/PluginController.php @@ -8,7 +8,7 @@ * obtain one at https://mozilla.org/MPL/2.0/. * * @package phpMyFAQ - * @author Thorsten Rinne + * @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 diff --git a/phpmyfaq/src/phpMyFAQ/Database/DatabaseDriver.php b/phpmyfaq/src/phpMyFAQ/Database/DatabaseDriver.php index 88c89bdcd3..25dd802920 100644 --- a/phpmyfaq/src/phpMyFAQ/Database/DatabaseDriver.php +++ b/phpmyfaq/src/phpMyFAQ/Database/DatabaseDriver.php @@ -137,4 +137,22 @@ public function close(); * @return string String that you can pass to SQL as in: SELECT now()> */ public function now(): string; + + /** + * Prepares a statement for execution and returns a statement object. + * + * @param string $query The SQL query + * @param array $options The driver options + * @return mixed + */ + public function prepare(string $query, array $options = []): mixed; + + /** + * Executes a prepared statement. + * + * @param mixed $statement The prepared statement + * @param array $params The parameters + * @return bool + */ + public function execute(mixed $statement, array $params = []): bool; } diff --git a/phpmyfaq/src/phpMyFAQ/Database/Mysqli.php b/phpmyfaq/src/phpMyFAQ/Database/Mysqli.php index a72ab70d23..23026389e4 100644 --- a/phpmyfaq/src/phpMyFAQ/Database/Mysqli.php +++ b/phpmyfaq/src/phpMyFAQ/Database/Mysqli.php @@ -27,6 +27,32 @@ use phpMyFAQ\Database; use SensitiveParameter; +/** + * Class MysqliStatement + */ +class MysqliStatement +{ + public function __construct( + public \mysqli_stmt $stmt, + public mixed $result = null, + ) { + } + + /** + * @deprecated Use DatabaseDriver::fetchAll() instead. + */ + public function fetchAll(): array + { + $ret = []; + if ($this->result) { + while ($row = $this->result->fetch_object()) { + $ret[] = $row; + } + } + return $ret; + } +} + /** * Class Mysqli * @@ -140,6 +166,10 @@ public function fetchRow(mixed $result): mixed */ public function fetchAll(mixed $result): ?array { + if ($result instanceof MysqliStatement) { + $result = $result->result; + } + $ret = []; if (false === $result) { throw new Exception('Error while fetching result: ' . $this->error()); @@ -252,7 +282,7 @@ public function getTableNames(string $prefix = ''): array $prefix . 'faquser_right', $prefix . 'faqvisits', $prefix . 'faqvoting', - $prefix . 'faqdata_plugins', + $prefix . 'faqplugins', ]; } @@ -316,6 +346,62 @@ public function query(string $query, int $offset = 0, int $rowcount = 0): mixed return $result; } + /** + * Prepares a statement for execution and returns a statement object. + * + * @param string $query The SQL query + * @param array $options The driver options + * @return MysqliStatement|false + */ + public function prepare(string $query, array $options = []): MysqliStatement|false + { + $stmt = $this->conn->prepare($query); + if ($stmt) { + return new MysqliStatement($stmt); + } + return false; + } + + /** + * Executes a prepared statement. + * + * @param mixed $statement The prepared statement + * @param array $params The parameters + * @return bool + */ + public function execute(mixed $statement, array $params = []): bool + { + if (!$statement instanceof MysqliStatement) { + return false; + } + + if (!empty($params)) { + $types = ''; + foreach ($params as $param) { + if (is_int($param)) { + $types .= 'i'; + } elseif (is_float($param)) { + $types .= 'd'; + } elseif (is_string($param)) { + $types .= 's'; + } else { + $types .= 'b'; + } + } + $statement->stmt->bind_param($types, ...$params); + } + + $success = $statement->stmt->execute(); + if ($success) { + $result = $statement->stmt->get_result(); ++ if ($result !== false) { ++ $statement->result = $result; ++ } + } + + return $success; + } + /** * Returns the client version string. */ diff --git a/phpmyfaq/src/phpMyFAQ/Database/PdoMysql.php b/phpmyfaq/src/phpMyFAQ/Database/PdoMysql.php index 4bf268d533..47079e3922 100644 --- a/phpmyfaq/src/phpMyFAQ/Database/PdoMysql.php +++ b/phpmyfaq/src/phpMyFAQ/Database/PdoMysql.php @@ -219,7 +219,7 @@ public function getTableNames(string $prefix = ''): array $prefix . 'faquser_right', $prefix . 'faqvisits', $prefix . 'faqvoting', - $prefix . 'faqdata_plugins', + $prefix . 'faqplugins', ]; } diff --git a/phpmyfaq/src/phpMyFAQ/Database/PdoPgsql.php b/phpmyfaq/src/phpMyFAQ/Database/PdoPgsql.php index d5abe3dc97..c16ad887af 100644 --- a/phpmyfaq/src/phpMyFAQ/Database/PdoPgsql.php +++ b/phpmyfaq/src/phpMyFAQ/Database/PdoPgsql.php @@ -218,7 +218,7 @@ public function getTableNames(string $prefix = ''): array $prefix . 'faquser_right', $prefix . 'faqvisits', $prefix . 'faqvoting', - $prefix . 'faqdata_plugins', + $prefix . 'faqplugins', ]; } diff --git a/phpmyfaq/src/phpMyFAQ/Database/PdoSqlite.php b/phpmyfaq/src/phpMyFAQ/Database/PdoSqlite.php index 96d34f27c5..2d9b97c5cf 100644 --- a/phpmyfaq/src/phpMyFAQ/Database/PdoSqlite.php +++ b/phpmyfaq/src/phpMyFAQ/Database/PdoSqlite.php @@ -243,7 +243,7 @@ public function getTableNames(string $prefix = ''): array $prefix . 'faquser_right', $prefix . 'faqvisits', $prefix . 'faqvoting', - $prefix . 'faqdata_plugins', + $prefix . 'faqplugins', ]; } diff --git a/phpmyfaq/src/phpMyFAQ/Database/PdoSqlsrv.php b/phpmyfaq/src/phpMyFAQ/Database/PdoSqlsrv.php index 0c7ec09102..811bbc21fb 100644 --- a/phpmyfaq/src/phpMyFAQ/Database/PdoSqlsrv.php +++ b/phpmyfaq/src/phpMyFAQ/Database/PdoSqlsrv.php @@ -219,7 +219,7 @@ public function getTableNames(string $prefix = ''): array $prefix . 'faquser_right', $prefix . 'faqvisits', $prefix . 'faqvoting', - $prefix . 'faqdata_plugins', + $prefix . 'faqplugins', ]; } diff --git a/phpmyfaq/src/phpMyFAQ/Database/Pgsql.php b/phpmyfaq/src/phpMyFAQ/Database/Pgsql.php index 73ed5f7d43..5fadc8dbc4 100644 --- a/phpmyfaq/src/phpMyFAQ/Database/Pgsql.php +++ b/phpmyfaq/src/phpMyFAQ/Database/Pgsql.php @@ -27,6 +27,37 @@ use phpMyFAQ\Database; use SensitiveParameter; +/** + * Class PgsqlStatement + */ +class PgsqlStatement +{ + public function __construct( + private string $name, + public mixed $result = null + ) { + } + + public function __toString(): string + { + return $this->name; + } + + /** + * @deprecated Use DatabaseDriver::fetchAll() instead. + */ + public function fetchAll(): array + { + $ret = []; + if ($this->result) { + while ($row = pg_fetch_object($this->result)) { + $ret[] = $row; + } + } + return $ret; + } +} + /** * Class Pgsql * @@ -52,6 +83,11 @@ class Pgsql implements DatabaseDriver */ private Connection|bool $conn = false; + /** + * Prepared statements. + */ + private array $preparedStatements = []; + /** * Connects to the database. * @@ -145,6 +181,10 @@ public function escape(string $string): string */ public function fetchAll(mixed $result): ?array { + if ($result instanceof PgsqlStatement) { + $result = $result->result; + } + $ret = []; if (false === $result) { throw new Exception('Error while fetching result: ' . $this->error()); @@ -314,10 +354,46 @@ public function getTableNames(string $prefix = ''): array $prefix . 'faquser_right', $prefix . 'faqvisits', $prefix . 'faqvoting', - $prefix . 'faqdata_plugins', + $prefix . 'faqplugins', ]; } + /** + * Prepares a statement for execution and returns a statement object. + * + * @param string $query The SQL query + * @param array $options The driver options + * @return PgsqlStatement|false + */ + public function prepare(string $query, array $options = []): PgsqlStatement|false + { + $stmtName = 'pmf_stmt_' . (count($this->preparedStatements) + 1); + if (pg_prepare($this->conn, $stmtName, $query)) { + $this->preparedStatements[$stmtName] = $query; + return new PgsqlStatement($stmtName); + } + return false; + } + + /** + * Executes a prepared statement. + * + * @param mixed $statement The prepared statement (name) + * @param array $params The parameters + * @return bool + */ + public function execute(mixed $statement, array $params = []): bool + { + $name = (string) $statement; + $result = pg_execute($this->conn, $name, $params); + + if ($statement instanceof PgsqlStatement) { + $statement->result = $result; + } + + return (bool) $result; + } + /** * Closes the connection to the database. */ diff --git a/phpmyfaq/src/phpMyFAQ/Database/Sqlite3.php b/phpmyfaq/src/phpMyFAQ/Database/Sqlite3.php index f8b5dc634d..ad890e72bc 100644 --- a/phpmyfaq/src/phpMyFAQ/Database/Sqlite3.php +++ b/phpmyfaq/src/phpMyFAQ/Database/Sqlite3.php @@ -22,6 +22,32 @@ use phpMyFAQ\Core\Exception; use SensitiveParameter; +/** + * Class Sqlite3Statement + */ +class Sqlite3Statement +{ + public function __construct( + public \SQLite3Stmt $stmt, + public mixed $result = null, + ) { + } + + /** + * @deprecated Use DatabaseDriver::fetchAll() instead. + */ + public function fetchAll(): array + { + $ret = []; + if ($this->result) { + while ($row = $this->result->fetchArray(SQLITE3_ASSOC)) { + $ret[] = (object) $row; + } + } + return $ret; + } +} + /** * Class Sqlite3 * @@ -113,6 +139,10 @@ public function fetchRow(mixed $result): mixed */ public function fetchAll(mixed $result): ?array { + if ($result instanceof Sqlite3Statement) { + $result = $result->result; + } + $ret = []; if (false === $result) { throw new Exception('Error while fetching result: ' . $this->error()); @@ -270,7 +300,7 @@ public function getTableNames(string $prefix = ''): array $prefix . 'faquser_right', $prefix . 'faqvisits', $prefix . 'faqvoting', - $prefix . 'faqdata_plugins', + $prefix . 'faqplugins', ]; } @@ -304,6 +334,58 @@ public function clientVersion(): string return $version['versionString']; } + /** + * Prepares a statement for execution and returns a statement object. + * + * @param string $query The SQL query + * @param array $options The driver options + * @return Sqlite3Statement|false + */ + public function prepare(string $query, array $options = []): Sqlite3Statement|false + { + $stmt = $this->conn->prepare($query); + if ($stmt) { + return new Sqlite3Statement($stmt); + } + return false; + } + + /** + * Executes a prepared statement. + * + * @param mixed $statement The prepared statement + * @param array $params The parameters + * @return bool + */ + public function execute(mixed $statement, array $params = []): bool + { + if (!$statement instanceof Sqlite3Statement) { + return false; + } + + foreach ($params as $index => $param) { + $type = SQLITE3_TEXT; + if (is_int($param)) { + $type = SQLITE3_INTEGER; + } elseif (is_float($param)) { + $type = SQLITE3_FLOAT; + } elseif (is_null($param)) { + $type = SQLITE3_NULL; + } elseif (is_bool($param)) { ++ $type = SQLITE3_INTEGER; ++ $param = $param ? 1 : 0; + } + $statement->stmt->bindValue($index + 1, $param, $type); + } + + $result = $statement->stmt->execute(); + if ($result) { + $statement->result = $result; + } + + return (bool) $result; + } + /** * Closes the connection to the database. */ diff --git a/phpmyfaq/src/phpMyFAQ/Database/Sqlsrv.php b/phpmyfaq/src/phpMyFAQ/Database/Sqlsrv.php index fdb3878f2e..08b2721ca1 100644 --- a/phpmyfaq/src/phpMyFAQ/Database/Sqlsrv.php +++ b/phpmyfaq/src/phpMyFAQ/Database/Sqlsrv.php @@ -24,6 +24,37 @@ use phpMyFAQ\Database; use SensitiveParameter; +/** + * Class SqlsrvStatement + */ +class SqlsrvStatement +{ + public function __construct( + public mixed $statement, + public mixed $result = null, + ) { + } + + public function __toString(): string + { + return is_string($this->statement) ? $this->statement : ''; + } + + /** + * @deprecated Use DatabaseDriver::fetchAll() instead. + */ + public function fetchAll(): array + { + $ret = []; + if ($this->result) { + while ($row = sqlsrv_fetch_object($this->result)) { + $ret[] = $row; + } + } + return $ret; + } +} + /** * Class Sqlsrv * @@ -113,6 +144,10 @@ public function fetchRow(mixed $result): mixed */ public function fetchAll(mixed $result): ?array { + if ($result instanceof SqlsrvStatement) { + $result = $result->result; + } + $ret = []; if (false === $result) { throw new Exception('Error while fetching result: ' . $this->error()); @@ -309,10 +344,62 @@ public function getTableNames(string $prefix = ''): array $prefix . 'faquser_right', $prefix . 'faqvisits', $prefix . 'faqvoting', - $prefix . 'faqdata_plugins', + $prefix . 'faqplugins', ]; } + /** + * Prepares a statement for execution and returns a statement object. + * + * @param string $query The SQL query + * @param array $options The driver options + * @return SqlsrvStatement|false + */ + public function prepare(string $query, array $options = []): SqlsrvStatement|false + { + if (str_contains($query, '?')) { + return new SqlsrvStatement($query); + } + + $stmt = sqlsrv_prepare($this->conn, $query, [], $options); + if ($stmt) { + return new SqlsrvStatement($stmt); + } + return false; + } + + /** + * Executes a prepared statement. + * + * @param mixed $statement The prepared statement (SqlsrvStatement resource or SQL string) + * @param array $params The parameters + * @return bool + */ + public function execute(mixed $statement, array $params = []): bool + { + if (!$statement instanceof SqlsrvStatement) { + return false; + } + + if (is_resource($statement->statement) && empty($params)) { + $success = sqlsrv_execute($statement->statement); + if ($success) { + $statement->result = $statement->statement; + } + return $success; + } + + if (is_string($statement->statement)) { + $result = sqlsrv_query($this->conn, $statement->statement, $params); + if ($result) { + $statement->result = $result; + } + return (bool) $result; + } + + return false; + } + /** * Closes the connection to the database. */ diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php index 886a8b846e..d1bc4c81f8 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Mysqli.php @@ -180,7 +180,7 @@ class Mysqli extends Database implements DriverInterface record_id INT(11) NOT NULL, group_id INT(11) NOT NULL, PRIMARY KEY (record_id, group_id))', - 'faqdata_plugins' => 'CREATE TABLE %sfaqdata_plugins ( + 'faqplugins' => 'CREATE TABLE %sfaqplugins ( name VARCHAR(255) NOT NULL, active INT(1) NOT NULL DEFAULT 0, config LONGTEXT DEFAULT NULL, diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoMysql.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoMysql.php index 81c1dc3a56..dd2611f0d4 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoMysql.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoMysql.php @@ -179,7 +179,7 @@ class PdoMysql extends Database implements DriverInterface record_id INT(11) NOT NULL, group_id INT(11) NOT NULL, PRIMARY KEY (record_id, group_id))', - 'faqdata_plugins' => 'CREATE TABLE %sfaqdata_plugins ( + 'faqplugins' => 'CREATE TABLE %sfaqplugins ( name VARCHAR(255) NOT NULL, active INT(1) NOT NULL DEFAULT 0, config LONGTEXT DEFAULT NULL, diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoPgsql.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoPgsql.php index 9961bbfb56..811a78ac8e 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoPgsql.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoPgsql.php @@ -176,7 +176,7 @@ class PdoPgsql extends Database implements DriverInterface record_id INTEGER NOT NULL, group_id INTEGER NOT NULL, PRIMARY KEY (record_id, group_id))', - 'faqdata_plugins' => 'CREATE TABLE %sfaqdata_plugins ( + 'faqplugins' => 'CREATE TABLE %sfaqplugins ( name VARCHAR(255) NOT NULL, active INTEGER NOT NULL DEFAULT 0, config TEXT DEFAULT NULL, diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlite.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlite.php index 58fda2c48a..c22dc4c3b6 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlite.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlite.php @@ -173,7 +173,7 @@ class PdoSqlite extends Database implements DriverInterface record_id INTEGER NOT NULL, group_id INTEGER NOT NULL, PRIMARY KEY (record_id, group_id))', - 'faqdata_plugins' => 'CREATE TABLE %sfaqdata_plugins ( + 'faqplugins' => 'CREATE TABLE %sfaqplugins ( name VARCHAR(255) NOT NULL, active INTEGER NOT NULL DEFAULT 0, config TEXT DEFAULT NULL, diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlsrv.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlsrv.php index 474ce413cf..eb271edba7 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlsrv.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/PdoSqlsrv.php @@ -173,7 +173,7 @@ class PdoSqlsrv extends Database implements DriverInterface record_id INTEGER NOT NULL, group_id INTEGER NOT NULL, PRIMARY KEY (record_id, group_id))', - 'faqdata_plugins' => 'CREATE TABLE %sfaqdata_plugins ( + 'faqplugins' => 'CREATE TABLE %sfaqplugins ( name NVARCHAR(255) NOT NULL, active INTEGER NOT NULL DEFAULT 0, config VARCHAR(MAX) DEFAULT NULL, diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php index 194f9c97d8..92a5289be1 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Pgsql.php @@ -177,7 +177,7 @@ class Pgsql extends Database implements DriverInterface record_id INTEGER NOT NULL, group_id INTEGER NOT NULL, PRIMARY KEY (record_id, group_id))', - 'faqdata_plugins' => 'CREATE TABLE %sfaqdata_plugins ( + 'faqplugins' => 'CREATE TABLE %sfaqplugins ( name VARCHAR(255) NOT NULL, active INTEGER NOT NULL DEFAULT 0, config TEXT DEFAULT NULL, diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlite3.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlite3.php index f8e6931417..6d63d3657b 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlite3.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlite3.php @@ -174,7 +174,7 @@ class Sqlite3 extends Database implements DriverInterface record_id INTEGER NOT NULL, group_id INTEGER NOT NULL, PRIMARY KEY (record_id, group_id))', - 'faqdata_plugins' => 'CREATE TABLE %sfaqdata_plugins ( + 'faqplugins' => 'CREATE TABLE %sfaqplugins ( name VARCHAR(255) NOT NULL, active INTEGER NOT NULL DEFAULT 0, config TEXT DEFAULT NULL, diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php index 05280e3a04..2245ed2697 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Database/Sqlsrv.php @@ -174,7 +174,7 @@ class Sqlsrv extends Database implements DriverInterface record_id INTEGER NOT NULL, group_id INTEGER NOT NULL, PRIMARY KEY (record_id, group_id))', - 'faqdata_plugins' => 'CREATE TABLE %sfaqdata_plugins ( + 'faqplugins' => 'CREATE TABLE %sfaqplugins ( name NVARCHAR(255) NOT NULL, active INTEGER NOT NULL DEFAULT 0, config NVARCHAR(MAX) DEFAULT NULL, diff --git a/phpmyfaq/src/phpMyFAQ/Plugin/PluginManager.php b/phpmyfaq/src/phpMyFAQ/Plugin/PluginManager.php index d0f359aee8..082be9b4e9 100644 --- a/phpmyfaq/src/phpMyFAQ/Plugin/PluginManager.php +++ b/phpmyfaq/src/phpMyFAQ/Plugin/PluginManager.php @@ -113,6 +113,10 @@ public function loadPlugins(): void $isActive = (bool) $dbPlugins[$pluginName]['active']; if (!empty($dbPlugins[$pluginName]['config']) && $plugin->getConfig()) { $configArray = json_decode($dbPlugins[$pluginName]['config'], true); + if (json_last_error() !== JSON_ERROR_NONE) { + error_log("Failed to decode config for plugin {$pluginName}: " . json_last_error_msg()); + continue; + } if (is_array($configArray)) { $configObject = $plugin->getConfig(); foreach ($configArray as $key => $value) { @@ -144,6 +148,7 @@ public function loadPlugins(): void } else { // I will default to inactive (false) for new plugins found on disk but not in DB. + $isActive = false; } @@ -157,6 +162,7 @@ public function loadPlugins(): void if (!empty($plugin->getConfig())) { $this->loadPluginConfig($plugin->getName(), $plugin->getConfig()); + // Apply DB config overrides here if possible } @@ -188,6 +194,7 @@ public function loadPlugins(): void $this->registerPluginScripts($plugin->getName(), $plugin->getScripts()); } } elseif (!$this->areDependenciesMet($plugin)) { + $missingDeps = $this->getMissingDependencies($plugin); $this->incompatiblePlugins[$plugin->getName()] = [ 'plugin' => $plugin, @@ -231,28 +238,22 @@ public function savePluginConfig(string $pluginName, array $configData): void { $jsonConfig = json_encode($configData); $db = $this->configuration->getDb(); - $table = \phpMyFAQ\Database::getTablePrefix() . 'faqdata_plugins'; + $table = \phpMyFAQ\Database::getTablePrefix() . 'faqplugins'; // Check if exists - $select = sprintf("SELECT name FROM %s WHERE name = '%s'", $table, $db->escape($pluginName)); - $result = $db->query($select); - - if ($db->numRows($result) > 0) { - $update = sprintf( - "UPDATE %s SET config = '%s' WHERE name = '%s'", - $table, - $db->escape($jsonConfig), - $db->escape($pluginName) - ); - $db->query($update); + $select = sprintf('SELECT name FROM %s WHERE name = ?', $table); + $stmt = $db->prepare($select); + $db->execute($stmt, [$pluginName]); + $result = $db->fetchAll($stmt); + + if (count($result) > 0) { + $update = sprintf('UPDATE %s SET config = ? WHERE name = ?', $table); + $stmt = $db->prepare($update); + $db->execute($stmt, [$jsonConfig, $pluginName]); } else { - $insert = sprintf( - "INSERT INTO %s (name, active, config) VALUES ('%s', 0, '%s')", - $table, - $db->escape($pluginName), - $db->escape($jsonConfig) - ); - $db->query($insert); + $insert = sprintf('INSERT INTO %s (name, active, config) VALUES (?, 0, ?)', $table); + $stmt = $db->prepare($insert); + $db->execute($stmt, [$pluginName, $jsonConfig]); } } @@ -262,29 +263,24 @@ public function savePluginConfig(string $pluginName, array $configData): void private function updatePluginStatus(string $pluginName, bool $active): void { $db = $this->configuration->getDb(); - $table = \phpMyFAQ\Database::getTablePrefix() . 'faqdata_plugins'; + $table = \phpMyFAQ\Database::getTablePrefix() . 'faqplugins'; $activeInt = $active ? 1 : 0; // Check if exists - $select = sprintf("SELECT name FROM %s WHERE name = '%s'", $table, $db->escape($pluginName)); - $result = $db->query($select); - - if ($db->numRows($result) > 0) { - $query = sprintf( - "UPDATE %s SET active = %d WHERE name = '%s'", - $table, - $activeInt, - $db->escape($pluginName) - ); + $select = sprintf('SELECT name FROM %s WHERE name = ?', $table); + $stmt = $db->prepare($select); + $db->execute($stmt, [$pluginName]); + $result = $db->fetchAll($stmt); + + if (count($result) > 0) { + $query = sprintf('UPDATE %s SET active = ? WHERE name = ?', $table); + $params = [$activeInt, $pluginName]; } else { - $query = sprintf( - "INSERT INTO %s (name, active) VALUES ('%s', %d)", - $table, - $db->escape($pluginName), - $activeInt - ); + $query = sprintf('INSERT INTO %s (name, active) VALUES (?, ?)', $table); + $params = [$pluginName, $activeInt]; } - $db->query($query); + $stmt = $db->prepare($query); + $db->execute($stmt, $params); } /** @@ -293,7 +289,7 @@ private function updatePluginStatus(string $pluginName, bool $active): void private function getPluginsFromDatabase(): array { $db = $this->configuration->getDb(); - $table = \phpMyFAQ\Database::getTablePrefix() . 'faqdata_plugins'; + $table = \phpMyFAQ\Database::getTablePrefix() . 'faqplugins'; // Ensure table exists to avoid crashes during update/install if not yet run try { @@ -396,6 +392,11 @@ private function getPluginDirectory(string $pluginName): string return PMF_ROOT_DIR . '/content/plugins/' . $pluginName; } + /** + * Registers stylesheets for a plugin + * + * @param string[] $stylesheets Relative paths to CSS files + */ private function registerPluginStylesheets(string $pluginName, array $stylesheets): void { $pluginDir = $this->getPluginDirectory($pluginName); diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Update.php b/phpmyfaq/src/phpMyFAQ/Setup/Update.php index 004ba8025b..6178583b1d 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Update.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Update.php @@ -1132,7 +1132,7 @@ private function applyUpdates420Alpha(): void case 'mysqli': case 'pdo_mysql': $this->queries[] = sprintf( - 'CREATE TABLE %sfaqdata_plugins ( + 'CREATE TABLE %sfaqplugins ( name VARCHAR(255) NOT NULL, active INT(1) NOT NULL DEFAULT 0, config LONGTEXT DEFAULT NULL, @@ -1144,7 +1144,7 @@ private function applyUpdates420Alpha(): void case 'pgsql': case 'pdo_pgsql': $this->queries[] = sprintf( - 'CREATE TABLE %sfaqdata_plugins ( + 'CREATE TABLE %sfaqplugins ( name VARCHAR(255) NOT NULL, active INTEGER NOT NULL DEFAULT 0, config TEXT DEFAULT NULL, @@ -1155,7 +1155,7 @@ private function applyUpdates420Alpha(): void case 'sqlite3': case 'pdo_sqlite': $this->queries[] = sprintf( - 'CREATE TABLE %sfaqdata_plugins ( + 'CREATE TABLE %sfaqplugins ( name VARCHAR(255) NOT NULL, active INTEGER NOT NULL DEFAULT 0, config TEXT DEFAULT NULL, @@ -1166,7 +1166,7 @@ private function applyUpdates420Alpha(): void case 'sqlsrv': case 'pdo_sqlsrv': $this->queries[] = sprintf( - 'CREATE TABLE %sfaqdata_plugins ( + 'CREATE TABLE %sfaqplugins ( name NVARCHAR(255) NOT NULL, active INTEGER NOT NULL DEFAULT 0, config VARCHAR(MAX) DEFAULT NULL, diff --git a/phpmyfaq/translations/language_de.php b/phpmyfaq/translations/language_de.php index b0fc9b3500..9350b71a08 100755 --- a/phpmyfaq/translations/language_de.php +++ b/phpmyfaq/translations/language_de.php @@ -1543,6 +1543,11 @@ $PMF_LANG['msgErrorDetails'] = 'Fehlerdetails'; $PMF_LANG['msgTryAgain'] = 'Erneut versuchen'; $PMF_LANG['msgAdminLogExportCsv'] = "Admin-Logs als CSV exportieren"; +$PMF_LANG['msgPluginStatusSuccess'] = 'Plugin-Status erfolgreich aktualisiert.'; +$PMF_LANG['msgPluginStatusError'] = 'Plugin-Status konnte nicht aktualisiert werden:'; +$PMF_LANG['msgPluginConfigSuccess'] = 'Plugin-Konfiguration erfolgreich gespeichert.'; +$PMF_LANG['msgPluginConfigError'] = 'Konfiguration konnte nicht gespeichert werden:'; +$PMF_LANG['msgUnknownError'] = 'Unbekannter Fehler'; return $PMF_LANG; diff --git a/phpmyfaq/translations/language_en.php b/phpmyfaq/translations/language_en.php index 9bacb72847..0c2f0b1f18 100644 --- a/phpmyfaq/translations/language_en.php +++ b/phpmyfaq/translations/language_en.php @@ -1542,6 +1542,11 @@ $PMF_LANG['msgErrorDetails'] = 'Error Details'; $PMF_LANG['msgTryAgain'] = 'Try Again'; $PMF_LANG['msgAdminLogExportCsv'] = 'Export admin logs as CSV'; +$PMF_LANG['msgPluginStatusSuccess'] = 'Plugin status updated successfully.'; +$PMF_LANG['msgPluginStatusError'] = 'Failed to update plugin status:'; +$PMF_LANG['msgPluginConfigSuccess'] = 'Plugin configuration saved.'; +$PMF_LANG['msgPluginConfigError'] = 'Failed to save configuration:'; +$PMF_LANG['msgUnknownError'] = 'Unknown error'; return $PMF_LANG; From d495931a81cc11f77e2f48bf7a4c632864bf8f71 Mon Sep 17 00:00:00 2001 From: d0ubIeU Date: Thu, 8 Jan 2026 21:05:08 +0100 Subject: [PATCH 9/9] fix --- docs/plugins.md | 4 +++- .../Administration/Api/PluginController.php | 5 ++--- phpmyfaq/src/phpMyFAQ/Database/Mysqli.php | 11 +++-------- phpmyfaq/src/phpMyFAQ/Database/Pgsql.php | 2 +- phpmyfaq/src/phpMyFAQ/Database/Sqlite3.php | 9 ++------- phpmyfaq/src/phpMyFAQ/Database/Sqlsrv.php | 2 +- phpmyfaq/src/phpMyFAQ/Plugin/PluginManager.php | 6 ++---- 7 files changed, 14 insertions(+), 25 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 26f5ea5779..8d31817656 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -30,7 +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. +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 @@ -91,11 +91,13 @@ class MyPlugin implements PluginInterface 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 }}'; diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/PluginController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/PluginController.php index 67a2129251..cb7c1200c3 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/PluginController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/PluginController.php @@ -32,7 +32,7 @@ final class PluginController extends AbstractAdministrationController public function toggleStatus(Request $request): JsonResponse { $csrfToken = $request->headers->get('X-CSRF-Token'); - if (!Token::getInstance($this->session)->verifyToken($csrfToken, 'admin-plugins')) { + if (!Token::getInstance($this->session)->verifyToken('admin-plugins', $csrfToken)) { return new JsonResponse(['success' => false, 'message' => 'Invalid CSRF token'], 403); } @@ -79,8 +79,7 @@ public function saveConfig(Request $request): JsonResponse } $csrfToken = $data['csrf'] ?? $request->headers->get('X-CSRF-Token'); - if (!Token::getInstance($this->session)->verifyToken($csrfToken, 'admin-plugins')) { - return new JsonResponse(['success' => false, 'message' => 'Invalid CSRF token'], 403); + if (!Token::getInstance($this->session)->verifyToken('admin-plugins', $csrfToken)) { return new JsonResponse(['success' => false, 'message' => 'Invalid CSRF token'], 403); } $pluginManager = $this->container->get(id: 'phpmyfaq.plugin.plugin-manager'); diff --git a/phpmyfaq/src/phpMyFAQ/Database/Mysqli.php b/phpmyfaq/src/phpMyFAQ/Database/Mysqli.php index 26a224a992..59643bcd29 100644 --- a/phpmyfaq/src/phpMyFAQ/Database/Mysqli.php +++ b/phpmyfaq/src/phpMyFAQ/Database/Mysqli.php @@ -170,10 +170,6 @@ public function fetchAll(mixed $result): ?array $result = $result->result; } - if ($result instanceof MysqliStatement) { - $result = $result->result; - } - $ret = []; if (false === $result) { throw new Exception('Error while fetching result: ' . $this->error()); @@ -287,7 +283,6 @@ public function getTableNames(string $prefix = ''): array $prefix . 'faqvisits', $prefix . 'faqvoting', $prefix . 'faqplugins', - $prefix . 'faqplugins', ]; } @@ -399,9 +394,9 @@ public function execute(mixed $statement, array $params = []): bool $success = $statement->stmt->execute(); if ($success) { $result = $statement->stmt->get_result(); -+ if ($result !== false) { -+ $statement->result = $result; -+ } + if ($result !== false) { + $statement->result = $result; + } } return $success; diff --git a/phpmyfaq/src/phpMyFAQ/Database/Pgsql.php b/phpmyfaq/src/phpMyFAQ/Database/Pgsql.php index 5fadc8dbc4..df54ab6d1d 100644 --- a/phpmyfaq/src/phpMyFAQ/Database/Pgsql.php +++ b/phpmyfaq/src/phpMyFAQ/Database/Pgsql.php @@ -34,7 +34,7 @@ class PgsqlStatement { public function __construct( private string $name, - public mixed $result = null + public mixed $result = null, ) { } diff --git a/phpmyfaq/src/phpMyFAQ/Database/Sqlite3.php b/phpmyfaq/src/phpMyFAQ/Database/Sqlite3.php index a67d2eccbc..7f07dadf04 100644 --- a/phpmyfaq/src/phpMyFAQ/Database/Sqlite3.php +++ b/phpmyfaq/src/phpMyFAQ/Database/Sqlite3.php @@ -143,10 +143,6 @@ public function fetchAll(mixed $result): ?array $result = $result->result; } - if ($result instanceof Sqlite3Statement) { - $result = $result->result; - } - $ret = []; if (false === $result) { throw new Exception('Error while fetching result: ' . $this->error()); @@ -305,7 +301,6 @@ public function getTableNames(string $prefix = ''): array $prefix . 'faqvisits', $prefix . 'faqvoting', $prefix . 'faqplugins', - $prefix . 'faqplugins', ]; } @@ -377,8 +372,8 @@ public function execute(mixed $statement, array $params = []): bool } elseif (is_null($param)) { $type = SQLITE3_NULL; } elseif (is_bool($param)) { -+ $type = SQLITE3_INTEGER; -+ $param = $param ? 1 : 0; + $type = SQLITE3_INTEGER; + $param = $param ? 1 : 0; } $statement->stmt->bindValue($index + 1, $param, $type); } diff --git a/phpmyfaq/src/phpMyFAQ/Database/Sqlsrv.php b/phpmyfaq/src/phpMyFAQ/Database/Sqlsrv.php index 0e9c3665bf..8bec064e99 100644 --- a/phpmyfaq/src/phpMyFAQ/Database/Sqlsrv.php +++ b/phpmyfaq/src/phpMyFAQ/Database/Sqlsrv.php @@ -31,7 +31,7 @@ class SqlsrvStatement { public function __construct( public mixed $statement, - public mixed $result = null + public mixed $result = null, ) { } diff --git a/phpmyfaq/src/phpMyFAQ/Plugin/PluginManager.php b/phpmyfaq/src/phpMyFAQ/Plugin/PluginManager.php index 5b2a592a70..2effbe2a92 100644 --- a/phpmyfaq/src/phpMyFAQ/Plugin/PluginManager.php +++ b/phpmyfaq/src/phpMyFAQ/Plugin/PluginManager.php @@ -130,7 +130,7 @@ public function loadPlugins(): void $value = (int) $value; break; case 'bool': - $value = filter_var($value, FILTER_VALIDATE_BOOLEAN); + $value = (bool) $value; break; case 'float': $value = (float) $value; @@ -146,7 +146,6 @@ public function loadPlugins(): void } } } else { - // I will default to inactive (false) for new plugins found on disk but not in DB. $isActive = false; @@ -194,7 +193,6 @@ public function loadPlugins(): void $this->registerPluginScripts($plugin->getName(), $plugin->getScripts()); } } elseif (!$this->areDependenciesMet($plugin)) { - $missingDeps = $this->getMissingDependencies($plugin); $this->incompatiblePlugins[$plugin->getName()] = [ 'plugin' => $plugin, @@ -290,7 +288,7 @@ private function getPluginsFromDatabase(): array { $db = $this->configuration->getDb(); $table = \phpMyFAQ\Database::getTablePrefix() . 'faqplugins'; - + // Ensure table exists to avoid crashes during update/install if not yet run try { $result = $db->query("SELECT name, active, config FROM $table");