From 83636ba2b2054b3b3e0ee24f53c303bd350a0b69 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 23 Feb 2026 14:50:02 +0100 Subject: [PATCH 1/8] feat: added optional Redis support for configuration caching --- CHANGELOG.md | 1 + docker-compose.yml | 21 ++- docs/administration.md | 18 ++ docs/installation.md | 74 ++++++++ mkdocs.yml | 5 +- .../assets/src/api/configuration.test.ts | 44 +++++ .../admin/assets/src/api/configuration.ts | 18 ++ .../assets/src/api/fetch-wrapper.test.ts | 4 +- .../admin/assets/src/api/fetch-wrapper.ts | 4 +- .../src/configuration/configuration.test.ts | 59 ++++++ .../assets/src/configuration/configuration.ts | 126 +++++++++++++ .../templates/admin/configuration/macros.twig | 3 +- .../templates/admin/configuration/main.twig | 7 + .../admin/configuration/tab-list.twig | 1 + .../Configuration/ConfigurationRepository.php | 79 +++----- .../Storage/ConfigurationStorageSettings.php | 31 ++++ .../ConfigurationStorageSettingsResolver.php | 57 ++++++ .../Storage/ConfigurationStoreInterface.php | 36 ++++ .../Storage/DatabaseConfigurationStore.php | 128 +++++++++++++ .../Storage/HybridConfigurationStore.php | 166 +++++++++++++++++ .../Storage/RedisConfigurationStore.php | 173 ++++++++++++++++++ .../Api/ConfigurationController.php | 37 ++++ .../SystemInformationController.php | 5 +- .../Setup/Installation/DefaultDataSeeder.php | 4 + .../Migration/Versions/Migration420Alpha.php | 6 + phpmyfaq/translations/language_en.php | 28 +++ .../Auth/OAuth2/AuthorizationServerTest.php | 15 -- ...nfigurationStorageSettingsResolverTest.php | 67 +++++++ .../HybridConfigurationStoreTest.php | 85 +++++++++ .../Api/ConfigurationControllerTest.php | 42 +++++ .../Installation/DefaultDataSeederTest.php | 4 + .../Setup/Migration/MigrationRegistryTest.php | 6 +- 32 files changed, 1268 insertions(+), 86 deletions(-) create mode 100644 phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStorageSettings.php create mode 100644 phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStorageSettingsResolver.php create mode 100644 phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStoreInterface.php create mode 100644 phpmyfaq/src/phpMyFAQ/Configuration/Storage/DatabaseConfigurationStore.php create mode 100644 phpmyfaq/src/phpMyFAQ/Configuration/Storage/HybridConfigurationStore.php create mode 100644 phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php create mode 100644 tests/phpMyFAQ/Configuration/ConfigurationStorageSettingsResolverTest.php create mode 100644 tests/phpMyFAQ/Configuration/HybridConfigurationStoreTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a9116be8..8436ccd6a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ This is a log of major user-visible changes in each phpMyFAQ release. - added support for SendGrid, AWS SES, and Mailgun (Thorsten) - added theme manager with support for multiple themes and theme switching (Thorsten) - added Symfony Kernel for better application structure and extensibility (Thorsten) +- added optional Redis support for configuration caching (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) diff --git a/docker-compose.yml b/docker-compose.yml index 4abeb2c225..879407a4d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,12 @@ services: image: redis:7-alpine restart: always command: redis-server --appendonly yes + healthcheck: + test: [ 'CMD', 'redis-cli', 'ping' ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s ports: - '6379:6379' volumes: @@ -78,7 +84,10 @@ services: volumes: - ./phpmyfaq:/var/www/html depends_on: - - pnpm + pnpm: + condition: service_completed_successfully + redis: + condition: service_healthy nginx: image: nginx:latest @@ -123,7 +132,10 @@ services: volumes: - ./phpmyfaq:/var/www/html depends_on: - - pnpm + pnpm: + condition: service_completed_successfully + redis: + condition: service_healthy frankenphp: profiles: ['frankenphp'] @@ -161,7 +173,10 @@ services: - ./volumes/caddy_data:/data - ./volumes/caddy_config:/config depends_on: - - pnpm + pnpm: + condition: service_completed_successfully + redis: + condition: service_healthy pnpm: image: node:24-alpine diff --git a/docs/administration.md b/docs/administration.md index f0005d440b..f8682364af 100644 --- a/docs/administration.md +++ b/docs/administration.md @@ -796,6 +796,24 @@ Outgoing emails are queued by default (queue `mail`) and delivered by the backgr php bin/worker.php ``` +### 5.6.5 Redis-backed configuration storage + +The **Storage** tab allows enabling Redis as a configuration cache. + +- The database table `faqconfig` remains the source of truth. +- Redis is used as fast read storage. +- If Redis is unavailable, phpMyFAQ falls back to database reads/writes. + +Before enabling it in production: + +1. Configure DSN, prefix, and timeout in **Configuration → Storage** +2. Use **Test Redis connection** +3. Save configuration and verify application behavior + +For full setup examples and troubleshooting, see: + +- [Redis-Backed Configuration Storage](redis-configuration-storage.md) + ### 5.6.2 FAQ Multi-sites You can see a list of all multisite installations, and you're able to add new ones. diff --git a/docs/installation.md b/docs/installation.md index 8f52e201ed..3eba4c9157 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -24,6 +24,7 @@ Suggested PHP extensions: - mbstring - zlib +- redis (if you want to use Redis as an in-memory data store) ### Web server requirements @@ -783,3 +784,76 @@ frankenphp { 3. Restart FrankenPHP Worker mode significantly improves performance by keeping PHP code in memory between requests. + +## 2.20 Redis-Backed Configuration Storage + +phpMyFAQ can optionally use Redis to cache configuration values from `faqconfig`. + +- Source of truth remains the database (`faqconfig`) +- Redis is used as a fast read layer +- On write, phpMyFAQ updates database first, then Redis +- If Redis is unavailable, phpMyFAQ falls back to database reads/writes + +### Admin Setup + +Open **Administration → Configuration → Storage** and configure: + +- `Enable Redis for configuration storage` +- `Redis DSN for configuration storage` +- `Redis key prefix for configuration storage` +- `Redis connection timeout in seconds` + +Use **Test Redis connection** before enabling Redis storage. + +### DSN Examples + +- Docker Compose service name: + - `tcp://redis:6379?database=1` +- Local host: + - `tcp://127.0.0.1:6379?database=1` +- Unix socket: + - `unix:///var/run/redis/redis.sock?database=1` + +### Docker Notes + +`redis` as hostname works only if: + +- a `redis` service/container is running +- your PHP container and Redis container are in the same Docker network + +Check quickly: + +```bash +docker compose ps +docker compose exec php-fpm getent hosts redis +docker compose exec php-fpm sh -lc 'nc -zv redis 6379' +``` + +If `getaddrinfo for redis failed: Name or service not known`, either Redis is not running or hostname/networking is wrong. + +### Recommended Defaults + +- DSN: `tcp://redis:6379?database=1` +- Prefix: `pmf:config:` +- Timeout: `1.0` + +Use a unique prefix per phpMyFAQ instance when sharing one Redis server. + +### Troubleshooting + +#### Test button returns login redirect + +This means the session is no longer valid (HTTP 401). Log in again and retry. + +#### Test button says Redis connection failed + +Open browser dev tools and inspect the API JSON response for the concrete reason: + +- DNS/hostname resolution issue +- connection refused (Redis not listening) +- timeout +- invalid DSN + +#### Redis enabled, but the app still works when Redis is down + +This is expected behavior: phpMyFAQ falls back to the database. diff --git a/mkdocs.yml b/mkdocs.yml index 67aff2a831..434a58e3b4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,5 +15,6 @@ nav: - 8. AI-Assisted Translation Feature: 'ai-translation.md' - 9. Developer documentation: 'development.md' - 10. Plugins: 'plugins.md' - - 11. MCP Server: 'mcp-server.md' - - 12. Thank you!: 'thank-you.md' + - 11. Redis Configuration Storage: 'redis-configuration-storage.md' + - 12. MCP Server: 'mcp-server.md' + - 13. Thank you!: 'thank-you.md' diff --git a/phpmyfaq/admin/assets/src/api/configuration.test.ts b/phpmyfaq/admin/assets/src/api/configuration.test.ts index 30e99e9620..25cc27b6c8 100644 --- a/phpmyfaq/admin/assets/src/api/configuration.test.ts +++ b/phpmyfaq/admin/assets/src/api/configuration.test.ts @@ -11,6 +11,8 @@ import { fetchTemplates, fetchTranslations, saveConfiguration, + sendTestMail, + testRedisConnection, } from './configuration'; import * as fetchWrapperModule from './fetch-wrapper'; @@ -379,3 +381,45 @@ describe('saveConfiguration', () => { }); }); }); + +describe('sendTestMail', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should send test mail request with csrf payload', async () => { + const mockResponse = { success: true, message: 'ok' }; + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); + + const result = await sendTestMail('csrf-token'); + + expect(result).toEqual(mockResponse); + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/configuration/send-test-mail', { + method: 'POST', + body: JSON.stringify({ csrf: 'csrf-token' }), + }); + }); +}); + +describe('testRedisConnection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should send redis connection test request with payload', async () => { + const mockResponse = { success: true, message: 'Redis connection successful.' }; + vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse); + + const result = await testRedisConnection('csrf-token', 'tcp://redis:6379?database=1', 1.0); + + expect(result).toEqual(mockResponse); + expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/configuration/test-redis-connection', { + method: 'POST', + body: JSON.stringify({ + csrf: 'csrf-token', + redisDsn: 'tcp://redis:6379?database=1', + timeout: 1.0, + }), + }); + }); +}); diff --git a/phpmyfaq/admin/assets/src/api/configuration.ts b/phpmyfaq/admin/assets/src/api/configuration.ts index 1cca798924..43aa8f6fec 100644 --- a/phpmyfaq/admin/assets/src/api/configuration.ts +++ b/phpmyfaq/admin/assets/src/api/configuration.ts @@ -153,3 +153,21 @@ export const uploadThemeArchive = async (data: FormData): Promise => { body: data, })) as Response; }; + +export const sendTestMail = async (csrf: string): Promise => { + return (await fetchJson('api/configuration/send-test-mail', { + method: 'POST', + body: JSON.stringify({ csrf }), + })) as Response; +}; + +export const testRedisConnection = async (csrf: string, redisDsn: string, timeout: number): Promise => { + return (await fetchJson('api/configuration/test-redis-connection', { + method: 'POST', + body: JSON.stringify({ + csrf, + redisDsn, + timeout, + }), + })) as Response; +}; diff --git a/phpmyfaq/admin/assets/src/api/fetch-wrapper.test.ts b/phpmyfaq/admin/assets/src/api/fetch-wrapper.test.ts index 8168b854ad..5b5ccdb909 100644 --- a/phpmyfaq/admin/assets/src/api/fetch-wrapper.test.ts +++ b/phpmyfaq/admin/assets/src/api/fetch-wrapper.test.ts @@ -125,7 +125,7 @@ describe('fetchWrapper', () => { await fetchWrapper('/test', { method: 'GET' }); expect.fail('Should have thrown an error'); } catch { - expect(window.location.href).toBe('./admin/login'); + expect(window.location.href).toBe('./login'); } }); @@ -152,7 +152,7 @@ describe('fetchWrapper', () => { expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe('Session expired'); expect(sessionStorageMock['loginMessage']).toBe('Your session has expired. Please log in again.'); - expect(window.location.href).toBe('./admin/login'); + expect(window.location.href).toBe('./login'); } }); }); diff --git a/phpmyfaq/admin/assets/src/api/fetch-wrapper.ts b/phpmyfaq/admin/assets/src/api/fetch-wrapper.ts index 731c4cc58b..9c23096092 100644 --- a/phpmyfaq/admin/assets/src/api/fetch-wrapper.ts +++ b/phpmyfaq/admin/assets/src/api/fetch-wrapper.ts @@ -29,8 +29,8 @@ export const fetchWrapper = async (url: string, options?: RequestInit): Promise< // Store a flash message in sessionStorage to show after redirect sessionStorage.setItem('loginMessage', 'Your session has expired. Please log in again.'); - // Redirect to the login page - window.location.href = './admin/login'; + // Redirect to the admin login page + window.location.href = './login'; // Throw error to stop further processing throw new Error('Session expired'); diff --git a/phpmyfaq/admin/assets/src/configuration/configuration.test.ts b/phpmyfaq/admin/assets/src/configuration/configuration.test.ts index 56326d32d7..4db4cd1c25 100644 --- a/phpmyfaq/admin/assets/src/configuration/configuration.test.ts +++ b/phpmyfaq/admin/assets/src/configuration/configuration.test.ts @@ -14,6 +14,9 @@ import { handleSearchRelevance, handleSeoMetaTags, handleMailProvider, + handleSendTestMail, + handleTestRedisConnection, + setupRedisTestButtonState, } from './configuration'; import { fetchConfiguration, @@ -28,6 +31,8 @@ import { fetchTemplates, fetchTranslations, saveConfiguration, + sendTestMail, + testRedisConnection, } from '../api'; vi.mock('../api'); @@ -221,6 +226,60 @@ describe('Configuration Functions', () => { }); }); + describe('handleSendTestMail', () => { + it('should call send test mail API with csrf token', async () => { + document.body.innerHTML = ` + + `; + + (sendTestMail as Mock).mockResolvedValue({ success: true, message: 'ok' }); + + await handleSendTestMail(); + + expect(sendTestMail).toHaveBeenCalledWith('csrf-token'); + }); + }); + + describe('handleTestRedisConnection', () => { + it('should call redis test API with form values', async () => { + document.body.innerHTML = ` + + + + + `; + + (testRedisConnection as Mock).mockResolvedValue({ success: true, message: 'ok' }); + + await handleTestRedisConnection(); + + expect(testRedisConnection).toHaveBeenCalledWith('csrf-token', 'tcp://redis:6379?database=1', 1.5); + const button = document.getElementById('btn-phpmyfaq-storage-testRedisConnection') as HTMLButtonElement; + expect(button.disabled).toBe(false); + expect(button.innerHTML).toContain('Test Redis connection'); + }); + }); + + describe('setupRedisTestButtonState', () => { + it('should disable button when DSN is empty and enable it when DSN is set', () => { + document.body.innerHTML = ` + + + `; + + setupRedisTestButtonState(); + + const redisDsnInput = document.getElementById('edit[storage.redisDsn]') as HTMLInputElement; + const button = document.getElementById('btn-phpmyfaq-storage-testRedisConnection') as HTMLButtonElement; + expect(button.disabled).toBe(true); + + redisDsnInput.value = 'tcp://redis:6379?database=1'; + redisDsnInput.dispatchEvent(new Event('input')); + + expect(button.disabled).toBe(false); + }); + }); + describe('handleConfiguration', () => { it('should handle configuration tabs and load data', async () => { document.body.innerHTML = ` diff --git a/phpmyfaq/admin/assets/src/configuration/configuration.ts b/phpmyfaq/admin/assets/src/configuration/configuration.ts index a7b539fd73..0dfc11905e 100644 --- a/phpmyfaq/admin/assets/src/configuration/configuration.ts +++ b/phpmyfaq/admin/assets/src/configuration/configuration.ts @@ -28,6 +28,8 @@ import { fetchTemplates, fetchTranslations, fetchTranslationProvider, + sendTestMail, + testRedisConnection, uploadThemeArchive, saveConfiguration, } from '../api'; @@ -46,6 +48,7 @@ const TAB_TARGETS = [ '#api', '#upgrade', '#translation', + '#storage', '#push', '#ldap', ]; @@ -138,6 +141,11 @@ export const handleConfiguration = async (): Promise => { case '#mail': await handleSMTPPasswordToggle(); await handleMailProvider(); + await registerConfigurationActionButtons(); + break; + case '#storage': + await registerConfigurationActionButtons(); + setupRedisTestButtonState(); break; case '#translation': await handleTranslationProvider(); @@ -485,3 +493,121 @@ export const handleConfigurationTab = async (target: string): Promise => { } } }; + +const registerConfigurationActionButtons = async (): Promise => { + const actionButtons = document.querySelectorAll('[data-pmf-config-action]') as NodeListOf; + actionButtons.forEach((button: HTMLButtonElement): void => { + const action = button.dataset.pmfConfigAction ?? ''; + + if (action === 'send-mail-test') { + button.onclick = async (event: Event): Promise => { + event.preventDefault(); + await handleSendTestMail(); + }; + return; + } + + if (action === 'test-redis-connection') { + button.onclick = async (event: Event): Promise => { + event.preventDefault(); + await handleTestRedisConnection(); + }; + } + }); +}; + +export const handleSendTestMail = async (): Promise => { + const csrfInput = document.getElementById('pmf-csrf-token') as HTMLInputElement | null; + if (!csrfInput || csrfInput.value === '') { + pushErrorNotification('Missing CSRF token.'); + return; + } + + try { + const response = (await sendTestMail(csrfInput.value)) as unknown as Response; + if (response.success) { + pushNotification(response.message || 'Test email sent successfully.'); + return; + } + + const errorMessage = + response.error || + response.message || + (typeof response.success === 'string' ? response.success : '') || + 'Sending test email failed.'; + pushErrorNotification(errorMessage); + } catch (error) { + pushErrorNotification(error instanceof Error ? error.message : 'Sending test email failed.'); + } +}; + +export const handleTestRedisConnection = async (): Promise => { + const csrfInput = document.getElementById('pmf-csrf-token') as HTMLInputElement | null; + const redisDsnInput = document.getElementById('edit[storage.redisDsn]') as HTMLInputElement | null; + const timeoutInput = document.getElementById('edit[storage.redisConnectTimeout]') as HTMLInputElement | null; + const button = document.getElementById('btn-phpmyfaq-storage-testRedisConnection') as HTMLButtonElement | null; + + if (!csrfInput || csrfInput.value === '') { + pushErrorNotification('Missing CSRF token.'); + return; + } + + if (!redisDsnInput || redisDsnInput.value.trim() === '') { + pushErrorNotification('Redis DSN is required.'); + return; + } + + const originalButtonHtml = button?.innerHTML ?? ''; + if (button) { + button.disabled = true; + button.innerHTML = + ' Testing...'; + } + + const timeout = timeoutInput ? parseFloat(timeoutInput.value) : 1.0; + const timeoutValue = Number.isFinite(timeout) && timeout > 0 ? timeout : 1.0; + + try { + const response = (await testRedisConnection( + csrfInput.value, + redisDsnInput.value.trim(), + timeoutValue + )) as unknown as Response; + + if (response.success) { + pushNotification(response.message || 'Redis connection successful.'); + return; + } + + const errorMessage = + response.error || + response.message || + (typeof response.success === 'string' ? response.success : '') || + 'Redis connection test failed.'; + pushErrorNotification(errorMessage); + } catch (error) { + pushErrorNotification(error instanceof Error ? error.message : 'Redis connection test failed.'); + } finally { + if (button) { + button.disabled = false; + button.innerHTML = originalButtonHtml; + } + } +}; + +export const setupRedisTestButtonState = (): void => { + const redisDsnInput = document.getElementById('edit[storage.redisDsn]') as HTMLInputElement | null; + const button = document.getElementById('btn-phpmyfaq-storage-testRedisConnection') as HTMLButtonElement | null; + + if (!redisDsnInput || !button) { + return; + } + + const updateState = (): void => { + button.disabled = redisDsnInput.value.trim() === ''; + }; + + redisDsnInput.removeEventListener('input', updateState); + redisDsnInput.addEventListener('input', updateState); + updateState(); +}; diff --git a/phpmyfaq/assets/templates/admin/configuration/macros.twig b/phpmyfaq/assets/templates/admin/configuration/macros.twig index 4a7c9fa80f..1bb08c9803 100644 --- a/phpmyfaq/assets/templates/admin/configuration/macros.twig +++ b/phpmyfaq/assets/templates/admin/configuration/macros.twig @@ -80,13 +80,14 @@ {# button #} {% macro sendTestMailButton(label, key, description) %} + {% set action = key == 'mail.sendTestEmail' ? 'send-mail-test' : (key == 'storage.testRedisConnection' ? 'test-redis-connection' : '') %}
diff --git a/phpmyfaq/assets/templates/admin/configuration/main.twig b/phpmyfaq/assets/templates/admin/configuration/main.twig index 183b39617f..72d1e8e679 100644 --- a/phpmyfaq/assets/templates/admin/configuration/main.twig +++ b/phpmyfaq/assets/templates/admin/configuration/main.twig @@ -125,6 +125,12 @@ LDAP +
  • {{ 'upgradeControlCenter' | translate }}
  • @@ -150,6 +156,7 @@
    +
    diff --git a/phpmyfaq/assets/templates/admin/configuration/tab-list.twig b/phpmyfaq/assets/templates/admin/configuration/tab-list.twig index 28a711145b..a051d9bb40 100644 --- a/phpmyfaq/assets/templates/admin/configuration/tab-list.twig +++ b/phpmyfaq/assets/templates/admin/configuration/tab-list.twig @@ -5,6 +5,7 @@ 'records': 'recordsControlCenter', 'search': 'searchControlCenter', 'translation': 'msgTranslation', + 'storage': 'storageControlCenter', 'security': 'securityControlCenter', 'spam': 'spamControlCenter', 'layout': 'layoutControlCenter', diff --git a/phpmyfaq/src/phpMyFAQ/Configuration/ConfigurationRepository.php b/phpmyfaq/src/phpMyFAQ/Configuration/ConfigurationRepository.php index fcbc37764a..a788d38237 100644 --- a/phpmyfaq/src/phpMyFAQ/Configuration/ConfigurationRepository.php +++ b/phpmyfaq/src/phpMyFAQ/Configuration/ConfigurationRepository.php @@ -20,30 +20,36 @@ namespace phpMyFAQ\Configuration; use phpMyFAQ\Configuration as CoreConfiguration; +use phpMyFAQ\Configuration\Storage\ConfigurationStorageSettingsResolver; +use phpMyFAQ\Configuration\Storage\DatabaseConfigurationStore; +use phpMyFAQ\Configuration\Storage\HybridConfigurationStore; use phpMyFAQ\Database; -readonly class ConfigurationRepository +class ConfigurationRepository { + private DatabaseConfigurationStore $databaseConfigurationStore; + + private HybridConfigurationStore $hybridConfigurationStore; + public function __construct( private CoreConfiguration $coreConfiguration, private string $tableName = 'faqconfig', ) { + $this->databaseConfigurationStore = new DatabaseConfigurationStore( + $this->coreConfiguration->getDb(), + $tableName, + ); + $settingsResolver = new ConfigurationStorageSettingsResolver($this->databaseConfigurationStore); + $this->hybridConfigurationStore = new HybridConfigurationStore( + $this->databaseConfigurationStore, + $settingsResolver, + $this->coreConfiguration->getLogger(), + ); } public function updateConfigValue(string $key, string $value): bool { - $sql = <<<'SQL' - UPDATE %s%s SET config_value = '%s' WHERE config_name = '%s' - SQL; - $query = sprintf( - $sql, - Database::getTablePrefix(), - $this->tableName, - $this->coreConfiguration->getDb()->escape(trim($value)), - $this->coreConfiguration->getDb()->escape(trim($key)), - ); - - return (bool) $this->coreConfiguration->getDb()->query($query); + return $this->hybridConfigurationStore->updateConfigValue($key, $value); } /** @@ -51,61 +57,22 @@ public function updateConfigValue(string $key, string $value): bool */ public function fetchAll(): array { - $sql = <<<'SQL' - SELECT config_name, config_value FROM %s%s - SQL; - $query = sprintf($sql, Database::getTablePrefix(), $this->tableName); - - $result = $this->coreConfiguration->getDb()->query($query); - $rows = $this->coreConfiguration->getDb()->fetchAll($result); - return is_array($rows) ? $rows : []; + return $this->hybridConfigurationStore->fetchAll(); } public function insert(string $name, string $value): bool { - $sql = <<<'SQL' - INSERT INTO %s%s (config_name, config_value) VALUES ('%s', '%s') - SQL; - $query = sprintf( - $sql, - Database::getTablePrefix(), - $this->tableName, - $this->coreConfiguration->getDb()->escape(trim($name)), - $this->coreConfiguration->getDb()->escape(trim($value)), - ); - - return (bool) $this->coreConfiguration->getDb()->query($query); + return $this->hybridConfigurationStore->insert($name, $value); } public function delete(string $name): bool { - $sql = <<<'SQL' - DELETE FROM %s%s WHERE config_name = '%s' - SQL; - $query = sprintf( - $sql, - Database::getTablePrefix(), - $this->tableName, - $this->coreConfiguration->getDb()->escape(trim($name)), - ); - - return (bool) $this->coreConfiguration->getDb()->query($query); + return $this->hybridConfigurationStore->delete($name); } public function renameKey(string $currentKey, string $newKey): bool { - $sql = <<<'SQL' - UPDATE %s%s SET config_name = '%s' WHERE config_name = '%s' - SQL; - $query = sprintf( - $sql, - Database::getTablePrefix(), - $this->tableName, - $this->coreConfiguration->getDb()->escape(trim($newKey)), - $this->coreConfiguration->getDb()->escape(trim($currentKey)), - ); - - return (bool) $this->coreConfiguration->getDb()->query($query); + return $this->hybridConfigurationStore->renameKey($currentKey, $newKey); } /** diff --git a/phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStorageSettings.php b/phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStorageSettings.php new file mode 100644 index 0000000000..bdebc64b22 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStorageSettings.php @@ -0,0 +1,31 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-23 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Configuration\Storage; + +readonly class ConfigurationStorageSettings +{ + public function __construct( + public bool $enabled, + public string $redisDsn, + public string $redisPrefix, + public float $connectTimeout, + ) { + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStorageSettingsResolver.php b/phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStorageSettingsResolver.php new file mode 100644 index 0000000000..efabf79457 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStorageSettingsResolver.php @@ -0,0 +1,57 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-23 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Configuration\Storage; + +readonly class ConfigurationStorageSettingsResolver +{ + private const string DEFAULT_REDIS_DSN = 'tcp://redis:6379?database=1'; + private const string DEFAULT_REDIS_PREFIX = 'pmf:config:'; + private const float DEFAULT_CONNECT_TIMEOUT = 1.0; + + public function __construct( + private DatabaseConfigurationStore $databaseConfigurationStore, + ) { + } + + public function resolve(): ConfigurationStorageSettings + { + $enabledValue = strtolower( + (string) ($this->databaseConfigurationStore->fetchValue('storage.useRedisForConfiguration') ?? 'false'), + ); + $enabled = in_array($enabledValue, ['1', 'true', 'yes', 'on'], true); + + $redisDsn = trim((string) ($this->databaseConfigurationStore->fetchValue('storage.redisDsn') ?? '')); + if ($redisDsn === '') { + $redisDsn = self::DEFAULT_REDIS_DSN; + } + + $redisPrefix = (string) ($this->databaseConfigurationStore->fetchValue('storage.redisPrefix') ?? ''); + if ($redisPrefix === '') { + $redisPrefix = self::DEFAULT_REDIS_PREFIX; + } + + $connectTimeout = (float) ($this->databaseConfigurationStore->fetchValue('storage.redisConnectTimeout') ?? ''); + if ($connectTimeout <= 0) { + $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT; + } + + return new ConfigurationStorageSettings($enabled, $redisDsn, $redisPrefix, $connectTimeout); + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStoreInterface.php b/phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStoreInterface.php new file mode 100644 index 0000000000..baa069b4a3 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStoreInterface.php @@ -0,0 +1,36 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-23 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Configuration\Storage; + +interface ConfigurationStoreInterface +{ + public function updateConfigValue(string $key, string $value): bool; + + /** + * @return array + */ + public function fetchAll(): array; + + public function insert(string $name, string $value): bool; + + public function delete(string $name): bool; + + public function renameKey(string $currentKey, string $newKey): bool; +} diff --git a/phpmyfaq/src/phpMyFAQ/Configuration/Storage/DatabaseConfigurationStore.php b/phpmyfaq/src/phpMyFAQ/Configuration/Storage/DatabaseConfigurationStore.php new file mode 100644 index 0000000000..e5ed99c033 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Configuration/Storage/DatabaseConfigurationStore.php @@ -0,0 +1,128 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-23 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Configuration\Storage; + +use phpMyFAQ\Database; +use phpMyFAQ\Database\DatabaseDriver; + +readonly class DatabaseConfigurationStore implements ConfigurationStoreInterface +{ + public function __construct( + private DatabaseDriver $databaseDriver, + private string $tableName = 'faqconfig', + ) { + } + + public function updateConfigValue(string $key, string $value): bool + { + $sql = <<<'SQL' + UPDATE %s%s SET config_value = '%s' WHERE config_name = '%s' + SQL; + $query = sprintf( + $sql, + Database::getTablePrefix(), + $this->tableName, + $this->databaseDriver->escape(trim($value)), + $this->databaseDriver->escape(trim($key)), + ); + + return (bool) $this->databaseDriver->query($query); + } + + /** + * @return array + */ + public function fetchAll(): array + { + $sql = <<<'SQL' + SELECT config_name, config_value FROM %s%s + SQL; + $query = sprintf($sql, Database::getTablePrefix(), $this->tableName); + + $result = $this->databaseDriver->query($query); + $rows = $this->databaseDriver->fetchAll($result); + return is_array($rows) ? $rows : []; + } + + public function insert(string $name, string $value): bool + { + $sql = <<<'SQL' + INSERT INTO %s%s (config_name, config_value) VALUES ('%s', '%s') + SQL; + $query = sprintf( + $sql, + Database::getTablePrefix(), + $this->tableName, + $this->databaseDriver->escape(trim($name)), + $this->databaseDriver->escape(trim($value)), + ); + + return (bool) $this->databaseDriver->query($query); + } + + public function delete(string $name): bool + { + $sql = <<<'SQL' + DELETE FROM %s%s WHERE config_name = '%s' + SQL; + $query = sprintf( + $sql, + Database::getTablePrefix(), + $this->tableName, + $this->databaseDriver->escape(trim($name)), + ); + + return (bool) $this->databaseDriver->query($query); + } + + public function renameKey(string $currentKey, string $newKey): bool + { + $sql = <<<'SQL' + UPDATE %s%s SET config_name = '%s' WHERE config_name = '%s' + SQL; + $query = sprintf( + $sql, + Database::getTablePrefix(), + $this->tableName, + $this->databaseDriver->escape(trim($newKey)), + $this->databaseDriver->escape(trim($currentKey)), + ); + + return (bool) $this->databaseDriver->query($query); + } + + public function fetchValue(string $name): ?string + { + $sql = <<<'SQL' + SELECT config_value FROM %s%s WHERE config_name = '%s' + SQL; + $query = sprintf( + $sql, + Database::getTablePrefix(), + $this->tableName, + $this->databaseDriver->escape(trim($name)), + ); + + $result = $this->databaseDriver->query($query); + $row = $this->databaseDriver->fetchObject($result); + + return isset($row->config_value) ? (string) $row->config_value : null; + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Configuration/Storage/HybridConfigurationStore.php b/phpmyfaq/src/phpMyFAQ/Configuration/Storage/HybridConfigurationStore.php new file mode 100644 index 0000000000..82d773d3e8 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Configuration/Storage/HybridConfigurationStore.php @@ -0,0 +1,166 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-23 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Configuration\Storage; + +use Psr\Log\LoggerInterface; +use RuntimeException; + +readonly class HybridConfigurationStore implements ConfigurationStoreInterface +{ + public function __construct( + private DatabaseConfigurationStore $databaseConfigurationStore, + private ConfigurationStorageSettingsResolver $settingsResolver, + private LoggerInterface $logger, + ) { + } + + public function updateConfigValue(string $key, string $value): bool + { + $result = $this->databaseConfigurationStore->updateConfigValue($key, $value); + if (!$result) { + return false; + } + + $redisStore = $this->resolveRedisStore(); + if ($redisStore !== null) { + try { + $redisStore->updateConfigValue($key, $value); + } catch (RuntimeException $exception) { + $this->logger->warning('Failed to update configuration key in Redis storage.', [ + 'key' => $key, + 'error' => $exception->getMessage(), + ]); + } + } + + return true; + } + + /** + * @return array + */ + public function fetchAll(): array + { + $redisStore = $this->resolveRedisStore(); + if ($redisStore !== null) { + try { + $rows = $redisStore->fetchAll(); + if ($rows !== []) { + return $rows; + } + } catch (RuntimeException $exception) { + $this->logger->warning('Failed to fetch configuration from Redis storage.', [ + 'error' => $exception->getMessage(), + ]); + } + } + + $rows = $this->databaseConfigurationStore->fetchAll(); + + if ($redisStore !== null && $rows !== []) { + try { + $redisStore->warmFromRows($rows); + } catch (RuntimeException $exception) { + $this->logger->warning('Failed to warm Redis configuration storage from database.', [ + 'error' => $exception->getMessage(), + ]); + } + } + + return $rows; + } + + public function insert(string $name, string $value): bool + { + $result = $this->databaseConfigurationStore->insert($name, $value); + if (!$result) { + return false; + } + + $redisStore = $this->resolveRedisStore(); + if ($redisStore !== null) { + try { + $redisStore->insert($name, $value); + } catch (RuntimeException $exception) { + $this->logger->warning('Failed to insert configuration key into Redis storage.', [ + 'key' => $name, + 'error' => $exception->getMessage(), + ]); + } + } + + return true; + } + + public function delete(string $name): bool + { + $result = $this->databaseConfigurationStore->delete($name); + if (!$result) { + return false; + } + + $redisStore = $this->resolveRedisStore(); + if ($redisStore !== null) { + try { + $redisStore->delete($name); + } catch (RuntimeException $exception) { + $this->logger->warning('Failed to delete configuration key from Redis storage.', [ + 'key' => $name, + 'error' => $exception->getMessage(), + ]); + } + } + + return true; + } + + public function renameKey(string $currentKey, string $newKey): bool + { + $result = $this->databaseConfigurationStore->renameKey($currentKey, $newKey); + if (!$result) { + return false; + } + + $redisStore = $this->resolveRedisStore(); + if ($redisStore !== null) { + try { + $redisStore->renameKey($currentKey, $newKey); + } catch (RuntimeException $exception) { + $this->logger->warning('Failed to rename configuration key in Redis storage.', [ + 'currentKey' => $currentKey, + 'newKey' => $newKey, + 'error' => $exception->getMessage(), + ]); + } + } + + return true; + } + + private function resolveRedisStore(): ?RedisConfigurationStore + { + $settings = $this->settingsResolver->resolve(); + if (!$settings->enabled) { + return null; + } + + return new RedisConfigurationStore($settings); + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php b/phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php new file mode 100644 index 0000000000..3ff308ac20 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php @@ -0,0 +1,173 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-23 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Configuration\Storage; + +use Redis; +use RuntimeException; + +readonly class RedisConfigurationStore implements ConfigurationStoreInterface +{ + public function __construct( + private ConfigurationStorageSettings $settings, + ) { + } + + public function updateConfigValue(string $key, string $value): bool + { + $redis = $this->createRedisClient(); + return false !== $redis->hSet($this->getHashKey(), $key, $value); + } + + /** + * @return array + */ + public function fetchAll(): array + { + $redis = $this->createRedisClient(); + $entries = $redis->hGetAll($this->getHashKey()); + if (!is_array($entries) || $entries === []) { + return []; + } + + $rows = []; + foreach ($entries as $name => $value) { + $rows[] = (object) [ + 'config_name' => (string) $name, + 'config_value' => (string) $value, + ]; + } + + return $rows; + } + + public function insert(string $name, string $value): bool + { + $redis = $this->createRedisClient(); + return (bool) $redis->hSetNx($this->getHashKey(), $name, $value); + } + + public function delete(string $name): bool + { + $redis = $this->createRedisClient(); + return $redis->hDel($this->getHashKey(), $name) >= 0; + } + + public function renameKey(string $currentKey, string $newKey): bool + { + $redis = $this->createRedisClient(); + $oldValue = $redis->hGet($this->getHashKey(), $currentKey); + if ($oldValue === false) { + return false; + } + + $setResult = $redis->hSet($this->getHashKey(), $newKey, (string) $oldValue); + if ($setResult === false) { + return false; + } + + $redis->hDel($this->getHashKey(), $currentKey); + return true; + } + + /** + * @param array $rows + */ + public function warmFromRows(array $rows): bool + { + if ($rows === []) { + return true; + } + + $redis = $this->createRedisClient(); + $keyValueMap = []; + foreach ($rows as $row) { + if (!isset($row->config_name)) { + continue; + } + + $keyValueMap[(string) $row->config_name] = (string) ($row->config_value ?? ''); + } + + if ($keyValueMap === []) { + return true; + } + + return false !== $redis->hMSet($this->getHashKey(), $keyValueMap); + } + + private function getHashKey(): string + { + return $this->settings->redisPrefix . 'items'; + } + + private function createRedisClient(): Redis + { + if (!extension_loaded('redis')) { + throw new RuntimeException('Redis configuration storage requires the PHP redis extension (ext-redis).'); + } + + $parsedUrl = parse_url($this->settings->redisDsn); + if ($parsedUrl === false || !isset($parsedUrl['scheme'])) { + throw new RuntimeException('Invalid Redis DSN for configuration storage.'); + } + + $redis = new Redis(); + $scheme = strtolower((string) $parsedUrl['scheme']); + $timeout = $this->settings->connectTimeout; + + if ($scheme === 'redis' || $scheme === 'tcp') { + $host = $parsedUrl['host'] ?? '127.0.0.1'; + $port = (int) ($parsedUrl['port'] ?? 6379); + $connected = $redis->connect($host, $port, $timeout); + if ($connected !== true) { + throw new RuntimeException(sprintf('Unable to connect to Redis at %s:%d', $host, $port)); + } + } elseif ($scheme === 'unix') { + $path = $parsedUrl['path'] ?? ''; + if ($path === '') { + throw new RuntimeException('Invalid Redis unix socket DSN for configuration storage.'); + } + + $connected = $redis->connect($path, 0, $timeout); + if ($connected !== true) { + throw new RuntimeException(sprintf('Unable to connect to Redis unix socket at %s', $path)); + } + } else { + throw new RuntimeException(sprintf( + 'Unsupported Redis DSN scheme "%s" for configuration storage.', + $scheme, + )); + } + + if (isset($parsedUrl['pass']) && $parsedUrl['pass'] !== '') { + $redis->auth($parsedUrl['pass']); + } + + $database = 0; + if (isset($parsedUrl['query'])) { + parse_str($parsedUrl['query'], $queryParams); + $database = (int) ($queryParams['database'] ?? $queryParams['db'] ?? 0); + } + + $redis->select($database); + + return $redis; + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationController.php index cfa1332aa8..c69d4e367b 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationController.php @@ -23,6 +23,7 @@ use phpMyFAQ\Enums\AdminLogType; use phpMyFAQ\Enums\PermissionType; use phpMyFAQ\Mail; +use phpMyFAQ\Session\RedisSessionHandler; use phpMyFAQ\Session\Token; use phpMyFAQ\Translation; use Symfony\Component\HttpFoundation\JsonResponse; @@ -90,4 +91,40 @@ public function activateMaintenanceMode(Request $request): JsonResponse return $this->json(['success' => Translation::get(key: 'healthCheckOkay')], Response::HTTP_OK); } + + /** + * @throws Exception|\Exception + */ + #[Route( + path: 'configuration/test-redis-connection', + name: 'admin.api.configuration.test-redis-connection', + methods: ['POST'], + )] + public function testRedisConnection(Request $request): JsonResponse + { + $this->userHasPermission(PermissionType::CONFIGURATION_EDIT); + + $data = json_decode($request->getContent()); + + if (!Token::getInstance($this->session)->verifyToken('configuration', $data->csrf)) { + return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED); + } + + $redisDsn = trim((string) ($data->redisDsn ?? $this->configuration->get('storage.redisDsn') ?? '')); + $timeout = (float) ($data->timeout ?? $this->configuration->get('storage.redisConnectTimeout') ?? 1.0); + if ($timeout <= 0) { + $timeout = 1.0; + } + + try { + RedisSessionHandler::validateConnection($redisDsn, $timeout); + + return $this->json([ + 'success' => true, + 'message' => 'Redis connection successful.', + ], Response::HTTP_OK); + } catch (\Throwable $throwable) { + return $this->json(['error' => $throwable->getMessage()], Response::HTTP_BAD_REQUEST); + } + } } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/SystemInformationController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/SystemInformationController.php index 57f66750fc..1df34ca502 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/SystemInformationController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/SystemInformationController.php @@ -97,8 +97,9 @@ public function index(Request $request): Response 'Database Driver' => Database::getType(), 'Database Server Version' => $this->configuration->getDb()->serverVersion(), 'Database Client Version' => $this->configuration->getDb()->clientVersion(), - 'Elasticsearch Version' => $esInformation, - 'OpenSearch Version' => $openSearchInformation, + 'Elasticsearch Version' => $esInformation ?? 'n/a', + 'OpenSearch Version' => $openSearchInformation ?? 'n/a', + 'Redis Version' => 'n/a', ], 'translationInformation' => $translationStatistics, ]); diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php b/phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php index 02e3a6ad06..41ea7b33cf 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php @@ -287,6 +287,10 @@ private static function buildDefaultConfig(): array 'queue.transport' => 'database', 'session.handler' => 'files', 'session.redisDsn' => 'tcp://redis:6379?database=0', + 'storage.useRedisForConfiguration' => 'false', + 'storage.redisDsn' => 'tcp://redis:6379?database=1', + 'storage.redisPrefix' => 'pmf:config:', + 'storage.redisConnectTimeout' => '1.0', 'upgrade.dateLastChecked' => '', 'upgrade.lastDownloadedPackage' => '', 'upgrade.onlineUpdateEnabled' => 'false', diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php index 9ff611481b..63f27adfa8 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php @@ -237,6 +237,12 @@ public function up(OperationRecorder $recorder): void $recorder->addConfig('main.enableCommentEditor', 'false'); + // Redis support configuration + $recorder->addConfig('storage.useRedisForConfiguration', 'false'); + $recorder->addConfig('storage.redisDsn', 'tcp://redis:6379?database=1'); + $recorder->addConfig('storage.redisPrefix', 'pmf:config:'); + $recorder->addConfig('storage.redisConnectTimeout', '1.0'); + // Create the chat messages table if ($this->isMySql()) { $recorder->addSql(sprintf( diff --git a/phpmyfaq/translations/language_en.php b/phpmyfaq/translations/language_en.php index ccd519c79a..0486fd8e0d 100644 --- a/phpmyfaq/translations/language_en.php +++ b/phpmyfaq/translations/language_en.php @@ -1668,6 +1668,7 @@ $PMF_LANG['msgPushNewQuestion'] = 'New open question submitted'; $PMF_LANG['msgPushNotificationsDescription'] = 'Receive browser notifications when new FAQs are published or questions are submitted.'; $PMF_LANG['pushControlCenter'] = 'Push'; +$PMF_LANG['storageControlCenter'] = 'Storage'; $PMF_LANG['msgGenerateVapidKeys'] = 'Generate VAPID Keys'; $PMF_LANG['msgVapidKeysGenerated'] = 'VAPID keys have been generated successfully.'; $PMF_LANG['msgVapidKeysError'] = 'Failed to generate VAPID keys.'; @@ -1685,4 +1686,31 @@ $LANG_CONF['mail.mailgunRegion'] = ['input', 'Mailgun region', 'e.g., us, eu']; $LANG_CONF['mail.useQueue'] = ['checkbox', 'Use background worker queue for email delivery']; +$LANG_CONF['storage.useRedisForConfiguration'] = [ + 'checkbox', + 'Enable Redis for configuration storage', + 'If disabled, phpMyFAQ uses the database only.', +]; +$LANG_CONF['storage.redisDsn'] = [ + 'input', + 'Redis DSN for configuration storage', + 'Examples: tcp://redis:6379?database=1 (Docker service name), tcp://127.0.0.1:6379?database=1', +]; +$LANG_CONF['storage.redisPrefix'] = [ + 'input', + 'Redis key prefix for configuration storage', + 'Use different prefixes for multiple phpMyFAQ instances sharing one Redis server.', +]; +$LANG_CONF['storage.redisConnectTimeout'] = [ + 'input', + 'Redis connection timeout in seconds', + 'Default: 1.0', +]; +$LANG_CONF['storage.testRedisConnection'] = [ + 'button', + 'Test Redis connection using current settings', + 'The test uses DSN and timeout from this form.', +]; +$PMF_LANG['storage.testRedisConnection'] = 'Test Redis connection'; + return $PMF_LANG; diff --git a/tests/phpMyFAQ/Auth/OAuth2/AuthorizationServerTest.php b/tests/phpMyFAQ/Auth/OAuth2/AuthorizationServerTest.php index 04483599ec..1283e5ae96 100644 --- a/tests/phpMyFAQ/Auth/OAuth2/AuthorizationServerTest.php +++ b/tests/phpMyFAQ/Auth/OAuth2/AuthorizationServerTest.php @@ -30,21 +30,6 @@ public function testIssueTokenUsesConfiguredIssuer(): void $this->assertSame('no-store', $result['headers']['Cache-Control']); } - public function testIssueTokenThrowsWhenNotConfiguredAndLeagueMissing(): void - { - if (class_exists(\League\OAuth2\Server\AuthorizationServer::class)) { - $this->markTestSkipped('league/oauth2-server is installed in this environment.'); - } - - $configuration = $this->createMock(Configuration::class); - $server = new AuthorizationServer($configuration); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('OAuth2 server dependency not installed'); - - $server->issueToken(new Request()); - } - public function testIssueTokenThrowsWhenOauth2IsDisabled(): void { $configuration = $this->createMock(Configuration::class); diff --git a/tests/phpMyFAQ/Configuration/ConfigurationStorageSettingsResolverTest.php b/tests/phpMyFAQ/Configuration/ConfigurationStorageSettingsResolverTest.php new file mode 100644 index 0000000000..a7fc78453f --- /dev/null +++ b/tests/phpMyFAQ/Configuration/ConfigurationStorageSettingsResolverTest.php @@ -0,0 +1,67 @@ +databaseFile = PMF_TEST_DIR . '/config-storage-settings-' . uniqid('', true) . '.db'; + $this->databaseDriver = new Sqlite3(); + $this->databaseDriver->connect($this->databaseFile, '', ''); + + Database::setTablePrefix(''); + $this->databaseDriver->query( + 'CREATE TABLE IF NOT EXISTS faqconfig (config_name VARCHAR(255) NOT NULL PRIMARY KEY, config_value TEXT)', + ); + + $this->databaseConfigurationStore = new DatabaseConfigurationStore($this->databaseDriver); + } + + protected function tearDown(): void + { + @unlink($this->databaseFile); + parent::tearDown(); + } + + public function testResolveReturnsDefaultsIfKeysAreMissing(): void + { + $resolver = new ConfigurationStorageSettingsResolver($this->databaseConfigurationStore); + $settings = $resolver->resolve(); + + $this->assertFalse($settings->enabled); + $this->assertSame('tcp://redis:6379?database=1', $settings->redisDsn); + $this->assertSame('pmf:config:', $settings->redisPrefix); + $this->assertSame(1.0, $settings->connectTimeout); + } + + public function testResolveReturnsConfiguredValues(): void + { + $this->databaseConfigurationStore->insert('storage.useRedisForConfiguration', 'true'); + $this->databaseConfigurationStore->insert('storage.redisDsn', 'tcp://127.0.0.1:6380?database=5'); + $this->databaseConfigurationStore->insert('storage.redisPrefix', 'custom:cfg:'); + $this->databaseConfigurationStore->insert('storage.redisConnectTimeout', '2.5'); + + $resolver = new ConfigurationStorageSettingsResolver($this->databaseConfigurationStore); + $settings = $resolver->resolve(); + + $this->assertTrue($settings->enabled); + $this->assertSame('tcp://127.0.0.1:6380?database=5', $settings->redisDsn); + $this->assertSame('custom:cfg:', $settings->redisPrefix); + $this->assertSame(2.5, $settings->connectTimeout); + } +} diff --git a/tests/phpMyFAQ/Configuration/HybridConfigurationStoreTest.php b/tests/phpMyFAQ/Configuration/HybridConfigurationStoreTest.php new file mode 100644 index 0000000000..eb674cb72f --- /dev/null +++ b/tests/phpMyFAQ/Configuration/HybridConfigurationStoreTest.php @@ -0,0 +1,85 @@ +databaseFile = PMF_TEST_DIR . '/hybrid-config-store-' . uniqid('', true) . '.db'; + $this->databaseDriver = new Sqlite3(); + $this->databaseDriver->connect($this->databaseFile, '', ''); + + Database::setTablePrefix(''); + $this->databaseDriver->query( + 'CREATE TABLE IF NOT EXISTS faqconfig (config_name VARCHAR(255) NOT NULL PRIMARY KEY, config_value TEXT)', + ); + + $this->databaseConfigurationStore = new DatabaseConfigurationStore($this->databaseDriver); + } + + protected function tearDown(): void + { + @unlink($this->databaseFile); + parent::tearDown(); + } + + public function testFetchAllFallsBackToDatabaseIfRedisIsUnavailable(): void + { + $this->databaseConfigurationStore->insert('main.language', 'en'); + $this->databaseConfigurationStore->insert('storage.useRedisForConfiguration', 'true'); + $this->databaseConfigurationStore->insert('storage.redisDsn', 'tcp://127.0.0.1:1?database=1'); + $this->databaseConfigurationStore->insert('storage.redisPrefix', 'pmf:config:'); + $this->databaseConfigurationStore->insert('storage.redisConnectTimeout', '0.1'); + + $store = new HybridConfigurationStore( + $this->databaseConfigurationStore, + new ConfigurationStorageSettingsResolver($this->databaseConfigurationStore), + new NullLogger(), + ); + + $rows = $store->fetchAll(); + $this->assertNotEmpty($rows); + + $configMap = []; + foreach ($rows as $row) { + $configMap[$row->config_name] = $row->config_value; + } + + $this->assertSame('en', $configMap['main.language']); + } + + public function testUpdateWritesToDatabaseWhenRedisIsUnavailable(): void + { + $this->databaseConfigurationStore->insert('main.language', 'en'); + $this->databaseConfigurationStore->insert('storage.useRedisForConfiguration', 'true'); + $this->databaseConfigurationStore->insert('storage.redisDsn', 'tcp://127.0.0.1:1?database=1'); + $this->databaseConfigurationStore->insert('storage.redisPrefix', 'pmf:config:'); + $this->databaseConfigurationStore->insert('storage.redisConnectTimeout', '0.1'); + + $store = new HybridConfigurationStore( + $this->databaseConfigurationStore, + new ConfigurationStorageSettingsResolver($this->databaseConfigurationStore), + new NullLogger(), + ); + + $this->assertTrue($store->updateConfigValue('main.language', 'de')); + $this->assertSame('de', $this->databaseConfigurationStore->fetchValue('main.language')); + } +} diff --git a/tests/phpMyFAQ/Controller/Administration/Api/ConfigurationControllerTest.php b/tests/phpMyFAQ/Controller/Administration/Api/ConfigurationControllerTest.php index a23e392f50..4febbce9f1 100644 --- a/tests/phpMyFAQ/Controller/Administration/Api/ConfigurationControllerTest.php +++ b/tests/phpMyFAQ/Controller/Administration/Api/ConfigurationControllerTest.php @@ -113,4 +113,46 @@ public function testActivateMaintenanceModeWithMissingCsrfTokenThrowsException() $this->expectException(\Exception::class); $controller->activateMaintenanceMode($request); } + + /** + * @throws Exception + */ + public function testTestRedisConnectionRequiresAuthentication(): void + { + $requestData = json_encode([ + 'csrf' => 'test-token', + 'redisDsn' => 'tcp://redis:6379?database=1', + 'timeout' => 1, + ]); + $request = new Request([], [], [], [], [], [], $requestData); + $controller = new ConfigurationController($this->mail); + + $this->expectException(\Exception::class); + $controller->testRedisConnection($request); + } + + /** + * @throws Exception + */ + public function testTestRedisConnectionWithInvalidJsonThrowsException(): void + { + $request = new Request([], [], [], [], [], [], 'invalid json'); + $controller = new ConfigurationController($this->mail); + + $this->expectException(\Exception::class); + $controller->testRedisConnection($request); + } + + /** + * @throws Exception + */ + public function testTestRedisConnectionWithMissingCsrfTokenThrowsException(): void + { + $requestData = json_encode([]); + $request = new Request([], [], [], [], [], [], $requestData); + $controller = new ConfigurationController($this->mail); + + $this->expectException(\Exception::class); + $controller->testRedisConnection($request); + } } diff --git a/tests/phpMyFAQ/Setup/Installation/DefaultDataSeederTest.php b/tests/phpMyFAQ/Setup/Installation/DefaultDataSeederTest.php index 236753c0e0..75eb4c59f5 100644 --- a/tests/phpMyFAQ/Setup/Installation/DefaultDataSeederTest.php +++ b/tests/phpMyFAQ/Setup/Installation/DefaultDataSeederTest.php @@ -31,6 +31,10 @@ public function testGetMainConfigContainsRequiredKeys(): void $this->assertArrayHasKey('spam.enableCaptchaCode', $config); $this->assertArrayHasKey('session.handler', $config); $this->assertArrayHasKey('session.redisDsn', $config); + $this->assertArrayHasKey('storage.useRedisForConfiguration', $config); + $this->assertArrayHasKey('storage.redisDsn', $config); + $this->assertArrayHasKey('storage.redisPrefix', $config); + $this->assertArrayHasKey('storage.redisConnectTimeout', $config); } public function testGetMainConfigHasDynamicValues(): void diff --git a/tests/phpMyFAQ/Setup/Migration/MigrationRegistryTest.php b/tests/phpMyFAQ/Setup/Migration/MigrationRegistryTest.php index 1e1505a937..0335421f0e 100644 --- a/tests/phpMyFAQ/Setup/Migration/MigrationRegistryTest.php +++ b/tests/phpMyFAQ/Setup/Migration/MigrationRegistryTest.php @@ -105,7 +105,7 @@ public function testGetPendingMigrationsFromOldVersion(): void public function testGetPendingMigrationsFromCurrentVersion(): void { - $pending = $this->registry->getPendingMigrations('4.2.0-alpha.2'); + $pending = $this->registry->getPendingMigrations('4.2.0-alpha'); $this->assertEmpty($pending); } @@ -151,10 +151,10 @@ public function testGetUnappliedMigrationsWithAllApplied(): void public function testMigrationsCaching(): void { - // First call should initialize migrations + // The first call should initialize migrations $migrations1 = $this->registry->getMigrations(); - // Second call should return cached migrations + // The second call should return cached migrations $migrations2 = $this->registry->getMigrations(); $this->assertSame($migrations1, $migrations2); From 31915c30d8c6ee88a9013b534f2c2f863e73dcfe Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 23 Feb 2026 15:14:37 +0100 Subject: [PATCH 2/8] fix: corrected review notes --- docker-compose.yml | 8 ++-- docs/administration.md | 42 +++++++++---------- docs/installation.md | 2 +- mkdocs.yml | 5 +-- .../assets/src/configuration/configuration.ts | 13 ++++-- .../templates/admin/configuration/macros.twig | 9 +++- .../templates/admin/configuration/main.twig | 2 +- .../Storage/RedisConfigurationStore.php | 24 ++++++++++- .../SystemInformationController.php | 34 ++++++++++++++- phpmyfaq/translations/language_en.php | 2 +- ...nfigurationStorageSettingsResolverTest.php | 2 + .../HybridConfigurationStoreTest.php | 2 + 12 files changed, 106 insertions(+), 39 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 879407a4d2..b40f2f1093 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,7 @@ services: - ./volumes/postgres:/var/lib/postgresql/data redis: - image: redis:7-alpine + image: redis:8-alpine restart: always command: redis-server --appendonly yes healthcheck: @@ -87,7 +87,7 @@ services: pnpm: condition: service_completed_successfully redis: - condition: service_healthy + condition: service_started nginx: image: nginx:latest @@ -135,7 +135,7 @@ services: pnpm: condition: service_completed_successfully redis: - condition: service_healthy + condition: service_started frankenphp: profiles: ['frankenphp'] @@ -176,7 +176,7 @@ services: pnpm: condition: service_completed_successfully redis: - condition: service_healthy + condition: service_started pnpm: image: node:24-alpine diff --git a/docs/administration.md b/docs/administration.md index f8682364af..ed733fd450 100644 --- a/docs/administration.md +++ b/docs/administration.md @@ -796,24 +796,6 @@ Outgoing emails are queued by default (queue `mail`) and delivered by the backgr php bin/worker.php ``` -### 5.6.5 Redis-backed configuration storage - -The **Storage** tab allows enabling Redis as a configuration cache. - -- The database table `faqconfig` remains the source of truth. -- Redis is used as fast read storage. -- If Redis is unavailable, phpMyFAQ falls back to database reads/writes. - -Before enabling it in production: - -1. Configure DSN, prefix, and timeout in **Configuration → Storage** -2. Use **Test Redis connection** -3. Save configuration and verify application behavior - -For full setup examples and troubleshooting, see: - -- [Redis-Backed Configuration Storage](redis-configuration-storage.md) - ### 5.6.2 FAQ Multi-sites You can see a list of all multisite installations, and you're able to add new ones. @@ -858,21 +840,39 @@ You can click through the update wizard: 5. Install downloaded package: first, it creates a backup of your current installation, then it copies the downloaded files into your installation, and in the end, the database is updated -### 5.6.5 Elasticsearch configuration +### 5.6.5 Redis-backed configuration storage + +The **Storage** tab allows enabling Redis as a configuration cache. + +- The database table `faqconfig` remains the source of truth. +- Redis is used as fast read storage. +- If Redis is unavailable, phpMyFAQ falls back to database reads/writes. + +Before enabling it in production: + +1. Configure DSN, prefix, and timeout in **Configuration → Storage** +2. Use **Test Redis connection** +3. Save configuration and verify application behavior + +For full setup examples and troubleshooting, see: + +- [Redis-Backed Configuration Storage](redis-configuration-storage.md) + +### 5.6.6 Elasticsearch configuration Here you can create and drop the Elasticsearch index, and you can run a full import of all data from your database into the Elasticsearch index. You can also see some Elasticsearch relevant usage data. This page is only available if Elasticsearch is enabled. -### 5.6.6 OpenSearch configuration +### 5.6.7 OpenSearch configuration Here you can create and drop the OpenSearch index, and you can run a full import of all data from your database into the OpenSearch index. You can also see some OpenSearch relevant usage data. This page is only available if OpenSearch is enabled. -### 5.6.7 System information +### 5.6.8 System information On this page, phpMyFAQ displays some relevant system information like PHP version, database version, or session path. Please use this information when reporting bugs. diff --git a/docs/installation.md b/docs/installation.md index 3eba4c9157..e1a8ef4463 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -70,7 +70,7 @@ The PDO extension is the preferred way to connect to your database server. ### Optional In-Memory Data Store -- [Redis](https://redis.io/) 8.x or later +- [Redis](https://redis.io/) 7.x or later ### Additional requirements diff --git a/mkdocs.yml b/mkdocs.yml index 434a58e3b4..67aff2a831 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,6 +15,5 @@ nav: - 8. AI-Assisted Translation Feature: 'ai-translation.md' - 9. Developer documentation: 'development.md' - 10. Plugins: 'plugins.md' - - 11. Redis Configuration Storage: 'redis-configuration-storage.md' - - 12. MCP Server: 'mcp-server.md' - - 13. Thank you!: 'thank-you.md' + - 11. MCP Server: 'mcp-server.md' + - 12. Thank you!: 'thank-you.md' diff --git a/phpmyfaq/admin/assets/src/configuration/configuration.ts b/phpmyfaq/admin/assets/src/configuration/configuration.ts index 0dfc11905e..a57a825c72 100644 --- a/phpmyfaq/admin/assets/src/configuration/configuration.ts +++ b/phpmyfaq/admin/assets/src/configuration/configuration.ts @@ -595,6 +595,8 @@ export const handleTestRedisConnection = async (): Promise => { } }; +let redisInputListener: (() => void) | null = null; + export const setupRedisTestButtonState = (): void => { const redisDsnInput = document.getElementById('edit[storage.redisDsn]') as HTMLInputElement | null; const button = document.getElementById('btn-phpmyfaq-storage-testRedisConnection') as HTMLButtonElement | null; @@ -603,11 +605,14 @@ export const setupRedisTestButtonState = (): void => { return; } - const updateState = (): void => { + if (redisInputListener) { + redisDsnInput.removeEventListener('input', redisInputListener); + } + + redisInputListener = (): void => { button.disabled = redisDsnInput.value.trim() === ''; }; - redisDsnInput.removeEventListener('input', updateState); - redisDsnInput.addEventListener('input', updateState); - updateState(); + redisDsnInput.addEventListener('input', redisInputListener); + redisInputListener(); }; diff --git a/phpmyfaq/assets/templates/admin/configuration/macros.twig b/phpmyfaq/assets/templates/admin/configuration/macros.twig index 1bb08c9803..b626aa7b60 100644 --- a/phpmyfaq/assets/templates/admin/configuration/macros.twig +++ b/phpmyfaq/assets/templates/admin/configuration/macros.twig @@ -80,7 +80,14 @@ {# button #} {% macro sendTestMailButton(label, key, description) %} - {% set action = key == 'mail.sendTestEmail' ? 'send-mail-test' : (key == 'storage.testRedisConnection' ? 'test-redis-connection' : '') %} + {% if key == 'mail.sendTestEmail' %} + {% set action = 'send-mail-test' %} + {% elseif key == 'storage.testRedisConnection' %} + {% set action = 'test-redis-connection' %} + {% else %} + {% set action = 'unsupported-action' %} + + {% endif %}