Skip to content

Comments

feat: added optional Redis support for configuration caching#4006

Merged
thorsten merged 8 commits intomainfrom
feat/redis
Feb 23, 2026
Merged

feat: added optional Redis support for configuration caching#4006
thorsten merged 8 commits intomainfrom
feat/redis

Conversation

@thorsten
Copy link
Owner

@thorsten thorsten commented Feb 23, 2026

Summary by CodeRabbit

  • New Features

    • Optional Redis-backed configuration caching with DB fallback
    • Storage tab in admin (DSN, key prefix, timeout) plus "Test Redis connection" and "Send test mail" actions
    • System information now shows Redis version
  • Documentation

    • Installation and administration docs updated with Redis setup, examples, defaults, troubleshooting and pre-checks
  • Chores

    • Docker Compose upgraded to Redis 8 with healthcheck and adjusted service start ordering
  • Tests

    • Added/expanded tests for Redis storage, resolver, hybrid store and controller
  • Bug Fixes

    • Adjusted 401 redirect path to ./login

@coderabbitai
Copy link

coderabbitai bot commented Feb 23, 2026

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds optional Redis-backed configuration storage: new storage interfaces and implementations (DB, Redis, hybrid), a settings resolver, admin UI and API for testing Redis, docs and docker updates, migration/default config entries, repository refactor to use the hybrid store, and tests.

Changes

Cohort / File(s) Summary
Docs & Compose
CHANGELOG.md, docs/administration.md, docs/installation.md, docker-compose.yml
Documented Redis-backed configuration storage and admin setup; updated installation notes; upgraded Redis image to redis:8-alpine, added Redis healthcheck and conditional service dependencies.
Frontend API & UI
phpmyfaq/admin/assets/src/api/configuration.ts, phpmyfaq/admin/assets/src/api/configuration.test.ts, phpmyfaq/admin/assets/src/api/fetch-wrapper.ts, phpmyfaq/admin/assets/src/api/fetch-wrapper.test.ts, phpmyfaq/admin/assets/src/configuration/..., phpmyfaq/assets/templates/admin/configuration/...
Added sendTestMail and testRedisConnection API wrappers and tests; changed 401 redirect path to ./login; wired config action buttons, Redis test button behavior, and added a Storage tab and template changes.
Storage Layer (PHP)
phpmyfaq/src/phpMyFAQ/Configuration/Storage/...
Introduced ConfigurationStoreInterface, DatabaseConfigurationStore, RedisConfigurationStore, HybridConfigurationStore, ConfigurationStorageSettings, and ConfigurationStorageSettingsResolver — DB/Redis/hybrid logic, DSN parsing, warming, and resilient Redis ops.
Repository & Core
phpmyfaq/src/phpMyFAQ/ConfigurationRepository.php, phpmyfaq/src/phpMyFAQ/Configuration.php
ConfigurationRepository now delegates to HybridConfigurationStore; added FAQ data retrieval/update by id; replaceMainReferenceUrl now updates items per-id during iteration.
Controllers & System Info
phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationController.php, phpmyfaq/src/phpMyFAQ/Controller/Administration/SystemInformationController.php
Added testRedisConnection API endpoint (CSRF and permission checks) and Redis version detection/display with safe fallbacks in system information.
Setup & Migrations
phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php, phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php
Seeded new storage.* Redis configuration keys in default data and migration.
Translations & Templates
phpmyfaq/translations/language_en.php, phpmyfaq/translations/language_de.php, phpmyfaq/assets/templates/admin/configuration/...
Added storage UI labels, Redis-related LANG_CONF entries and test button label; minor German label adjustment and template input/description restructuring.
Tests
tests/phpMyFAQ/..., phpmyfaq/admin/assets/src/.../*.test.ts
Added/updated tests: ConfigurationStorageSettingsResolverTest, HybridConfigurationStoreTest (fallbacks), ConfigurationController Redis endpoint tests, frontend config tests; removed one OAuth2 authorization test.
Changelog
CHANGELOG.md
Added v4.2.0-alpha entry for optional Redis support for configuration caching.

Sequence Diagram(s)

sequenceDiagram
    participant UI as Admin UI
    participant FE as Frontend API
    participant Ctrl as ConfigurationController
    participant Hybrid as HybridConfigurationStore
    participant Redis as Redis
    participant DB as Database

    UI->>FE: POST testRedisConnection(csrf, redisDsn, timeout)
    FE->>Ctrl: /api/configuration/test-redis-connection
    Ctrl->>Hybrid: resolve settings / validate connection
    Hybrid->>Redis: create client / ping / select DB
    alt Redis reachable
        Redis-->>Hybrid: OK
        Hybrid-->>Ctrl: success
        Ctrl-->>FE: 200 {message}
        FE-->>UI: show success
    else Redis fails
        Redis-->>Hybrid: error
        Hybrid->>DB: fetchAll() (fallback)
        DB-->>Hybrid: rows
        Hybrid-->>Ctrl: error/details
        Ctrl-->>FE: 400 {error}
        FE-->>UI: show error
    end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly Related PRs

Poem

🐇 I nibble keys and hop between,

Cache in Redis, DB unseen,
If Redis sleeps, the DB will guide,
Configs safe on every side,
A happy rabbit keeps watch wide.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.40% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'feat: added optional Redis support for configuration caching' accurately and concisely describes the primary change: introducing optional Redis as a cache layer for configuration values, which is the main focus across all modified files.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/redis

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

❤️ Share

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

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

Caution

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

⚠️ Outside diff range comments (2)
phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php (1)

39-42: ⚠️ Potential issue | 🟡 Minor

getDescription() does not reflect the new Redis storage config entries.

The description currently reads: 'Admin log hash columns, custom pages, chat messages, translation config, API rate limiting, queue jobs, mail provider config, API keys, OAuth2 tables'. The four storage.* config entries are not mentioned, making it harder for developers to understand the full scope of this migration.

📝 Proposed fix
     public function getDescription(): string
     {
-        return 'Admin log hash columns, custom pages, chat messages, translation config, API rate limiting, queue jobs, mail provider config, API keys, OAuth2 tables';
+        return 'Admin log hash columns, custom pages, chat messages, translation config, API rate limiting, queue jobs, mail provider config, API keys, OAuth2 tables, Redis-backed configuration storage settings';
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php` around
lines 39 - 42, Update the migration description returned by getDescription() in
Migration420Alpha so it lists the four new storage.* configuration entries added
by this migration; locate the getDescription() method in the Migration420Alpha
class and append a clear mention of the storage.* config changes (e.g.
"storage.* config entries") to the existing comma-separated description string
so the full scope — Admin log hash columns, custom pages, chat messages,
translation config, API rate limiting, queue jobs, mail provider config, API
keys, OAuth2 tables, and the storage.* config entries — is reflected.
phpmyfaq/src/phpMyFAQ/Configuration/ConfigurationRepository.php (1)

92-104: ⚠️ Potential issue | 🟠 Major

updateFaqDataContent uses content column for WHERE clause — will match and update all rows with identical content.

The method performs a bulk find-and-replace by matching on the exact content value. In replaceMainReferenceUrl(), this is called iteratively for each FAQ; if multiple FAQs share identical content, the WHERE clause will match all of them, causing unintended simultaneous updates. Consider whether content uniqueness should be enforced or if the method should operate by FAQ ID instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/src/phpMyFAQ/Configuration/ConfigurationRepository.php` around lines
92 - 104, The current updateFaqDataContent(string $oldContent, string
$newContent) updates rows by matching content which can update multiple FAQs
sharing identical content; change it to target a single FAQ by ID (e.g.,
add/replace with updateFaqDataContentById(int $faqId, string $newContent) or add
a $faqId parameter) and update the SQL to use the faq_id in the WHERE clause
instead of content. Ensure you escape or preferably parameterize the newContent
and faqId using the existing coreConfiguration->getDb() methods, update call
sites such as replaceMainReferenceUrl() to pass the specific FAQ id, and remove
the old content-based bulk-update usage.
🧹 Nitpick comments (14)
phpmyfaq/src/phpMyFAQ/Controller/Administration/SystemInformationController.php (1)

100-101: ?? 'n/a' null-coalescing operators are redundant

Both $esInformation and $openSearchInformation are guaranteed to be assigned before reaching these lines:

  • $esInformation is always set — either in the try/catch block (lines 55–63) or the explicit negative if at lines 65–67, which together cover the entire boolean space.
  • $openSearchInformation is unconditionally initialized to '' at line 69, so it can never be null or undefined.

The ?? 'n/a' operators are therefore dead code and can be dropped to keep the intent clear.

♻️ Proposed cleanup
-                'Elasticsearch Version' => $esInformation ?? 'n/a',
-                'OpenSearch Version' => $openSearchInformation ?? 'n/a',
+                'Elasticsearch Version' => $esInformation,
+                'OpenSearch Version' => $openSearchInformation,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@phpmyfaq/src/phpMyFAQ/Controller/Administration/SystemInformationController.php`
around lines 100 - 101, The null-coalescing fallbacks are redundant for the
'Elasticsearch Version' and 'OpenSearch Version' entries because $esInformation
is always assigned (via the try/catch and the explicit negative if) and
$openSearchInformation is initialized to '' before use; remove the "?? 'n/a'"
from the array entries so they read simply 'Elasticsearch Version' =>
$esInformation and 'OpenSearch Version' => $openSearchInformation in
SystemInformationController (class/method where these variables are prepared).
phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStoreInterface.php (2)

26-29: @return array<int, object> is overly broad — prefer array<int, \stdClass>.

Database fetch results are typically \stdClass instances. Narrowing the return type annotation improves static analysis and IDE support.

♻️ Proposed fix
     /**
-     * `@return` array<int, object>
+     * `@return` array<int, \stdClass>
      */
     public function fetchAll(): array;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStoreInterface.php`
around lines 26 - 29, The docblock for ConfigurationStoreInterface::fetchAll()
is too generic; update its `@return` annotation from "array<int, object>" to
"array<int, \stdClass>" so static analyzers and IDEs know the fetched DB rows
are stdClass instances; locate the fetchAll method in the
ConfigurationStoreInterface and replace the return type in the PHPDoc
accordingly while preserving the method signature.

24-35: Inconsistent parameter naming between $key and $name for the same concept.

updateConfigValue uses $key, while insert and delete use $name, and renameKey uses $currentKey/$newKey. Since all refer to the configuration identifier, pick one term (e.g., $key) and apply it consistently across all methods.

♻️ Proposed uniform naming
-    public function updateConfigValue(string $key, string $value): bool;
+    public function updateConfigValue(string $key, string $value): bool;  // already $key

-    public function insert(string $name, string $value): bool;
+    public function insert(string $key, string $value): bool;

-    public function delete(string $name): bool;
+    public function delete(string $key): bool;

-    public function renameKey(string $currentKey, string $newKey): bool;
+    public function renameKey(string $fromKey, string $toKey): bool;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStoreInterface.php`
around lines 24 - 35, The method parameter names are inconsistent:
updateConfigValue uses $key while insert/delete use $name and renameKey uses
$currentKey/$newKey; standardize to a single term (use $key) across the
interface. Update insert(string $name, string $value): bool to insert(string
$key, string $value): bool, delete(string $name): bool to delete(string $key):
bool, and renameKey(string $currentKey, string $newKey): bool to
renameKey(string $key, string $newKey): bool (or rename to renameKey(string
$oldKey, string $newKey): bool if you prefer old/new wording), and adjust any
associated phpdoc types/comments to reference $key consistently; keep
updateConfigValue(string $key, string $value): bool and fetchAll(): array
unchanged.
phpmyfaq/assets/templates/admin/configuration/macros.twig (1)

82-94: Macro name sendTestMailButton is misleading — rename to reflect its generic purpose.

The macro now handles any config action button (mail test, Redis test, future additions). The current name implies mail-only behaviour.

♻️ Suggested rename
-{% macro sendTestMailButton(label, key, description) %}
+{% macro generateActionButton(label, key, description) %}

Update all call sites in main.twig and tab-list.twig accordingly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/assets/templates/admin/configuration/macros.twig` around lines 82 -
94, Rename the misleading macro sendTestMailButton to a generic name (e.g.,
configActionButton) and update all call sites that reference it (notably in
main.twig and tab-list.twig) to use the new macro name; ensure the macro
signature ({% macro sendTestMailButton(label, key, description) %}) is renamed
consistently (e.g., {% macro configActionButton(label, key, description) %}) and
that the internal usage of key (id "btn-phpmyfaq-{{ key|replace({'.': '-'}) }}"
and data attribute data-pmf-config-action="{{ action }}") remains unchanged so
behavior is preserved.
phpmyfaq/admin/assets/src/configuration/configuration.test.ts (1)

229-281: Tests cover success paths; consider adding error-path coverage for completeness.

handleSendTestMail has no test for the missing-CSRF or API-failure case. handleTestRedisConnection has no test for the missing-DSN or API-error path. These are documented in the function docstrings as handled cases.

✨ Suggested additions
// Inside describe('handleSendTestMail')
it('should push error notification when CSRF token is missing', async () => {
  document.body.innerHTML = `<div></div>`; // no csrf input

  await handleSendTestMail();

  expect(sendTestMail).not.toHaveBeenCalled();
});

// Inside describe('handleTestRedisConnection')
it('should push error notification when Redis DSN is missing', async () => {
  document.body.innerHTML = `
    <input id="pmf-csrf-token" value="csrf-token" />
    <input id="edit[storage.redisConnectTimeout]" value="1.5" />
    <button id="btn-phpmyfaq-storage-testRedisConnection">Test Redis connection</button>
  `;

  await handleTestRedisConnection();

  expect(testRedisConnection).not.toHaveBeenCalled();
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/admin/assets/src/configuration/configuration.test.ts` around lines
229 - 281, The tests only cover success paths for handleSendTestMail and
handleTestRedisConnection; add negative-path tests: for handleSendTestMail add a
test that leaves out the pmf-csrf-token input, asserts sendTestMail is not
called and that the proper error/notification flow runs; for
handleTestRedisConnection add tests that omit edit[storage.redisDsn] (assert
testRedisConnection not called) and simulate API failure by mocking
testRedisConnection to reject or resolve with success: false and assert the
error/notification handling and button state; reference the functions
handleSendTestMail, handleTestRedisConnection, sendTestMail, testRedisConnection
and the button id btn-phpmyfaq-storage-testRedisConnection when adding these
tests.
phpmyfaq/admin/assets/src/api/configuration.ts (1)

157-173: Missing Content-Type: application/json header for JSON request bodies.

Both sendTestMail and testRedisConnection serialize the body with JSON.stringify but do not set the Content-Type header. While Symfony's $request->getContent() currently reads the raw body regardless, the missing header violates HTTP semantics and can cause issues with reverse proxies, WAFs, or middleware that inspect content type.

♻️ Proposed fix
 export const sendTestMail = async (csrf: string): Promise<Response> => {
   return (await fetchJson('api/configuration/send-test-mail', {
     method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({ csrf }),
   })) as Response;
 };
 
 export const testRedisConnection = async (csrf: string, redisDsn: string, timeout: number): Promise<Response> => {
   return (await fetchJson('api/configuration/test-redis-connection', {
     method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({
       csrf,
       redisDsn,
       timeout,
     }),
   })) as Response;
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/admin/assets/src/api/configuration.ts` around lines 157 - 173, Both
sendTestMail and testRedisConnection send JSON bodies but omit the Content-Type
header; update their fetchJson calls (in functions sendTestMail and
testRedisConnection) to include headers: { 'Content-Type': 'application/json' }
so the request properly advertises JSON payloads (ensure you merge with any
existing headers option rather than replacing other options).
phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationController.php (2)

122-125: Hardcoded success message is inconsistent with the rest of the controller.

activateMaintenanceMode returns Translation::get('healthCheckOkay') for its success message. Using a raw English string here creates an i18n inconsistency.

🌐 Proposed fix
 return $this->json([
     'success' => true,
-    'message' => 'Redis connection successful.',
+    'message' => Translation::get(key: 'msgRedisConnectionSuccessful'),
 ], Response::HTTP_OK);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationController.php`
around lines 122 - 125, The success response in ConfigurationController
currently returns a hardcoded English string ("Redis connection successful.");
replace that literal with the i18n helper used elsewhere (e.g.
Translation::get('healthCheckOkay') or a more specific translation key if
available) so the JSON response uses Translation::get(...) like
activateMaintenanceMode does, updating the 'message' value in the return
$this->json(...) statement to call Translation::get(...) instead of the raw
string.

113-117: Guard against an empty $redisDsn before delegating to validateConnection.

If neither the request nor the stored config provides a DSN, $redisDsn will be '', and RedisSessionHandler::validateConnection('') will propagate a low-quality error to the caller. An explicit early return gives a clearer response.

🛡️ Proposed guard
 $redisDsn = trim((string) ($data->redisDsn ?? $this->configuration->get('storage.redisDsn') ?? ''));
+if ($redisDsn === '') {
+    return $this->json(['error' => 'Redis DSN is required.'], Response::HTTP_BAD_REQUEST);
+}
 $timeout = (float) ($data->timeout ?? $this->configuration->get('storage.redisConnectTimeout') ?? 1.0);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationController.php`
around lines 113 - 117, The code calls RedisSessionHandler::validateConnection
with $redisDsn that may be an empty string; add an explicit guard in
ConfigurationController before calling validateConnection to check if
trim($redisDsn) === '' and short-circuit (return or set a clear error/validation
response) so you don't delegate an empty DSN to RedisSessionHandler; reference
the variables $redisDsn and $timeout and the method
RedisSessionHandler::validateConnection in your change.
phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php (2)

66-70: delete() returns true even when the key didn't exist.

hDel returns the count of removed fields. >= 0 is always true (it returns 0 or more). If the intent is idempotent delete, this is fine. If callers need to know whether the key was actually present, use > 0.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php`
around lines 66 - 70, The delete method in RedisConfigurationStore::delete uses
redis->hDel(... ) >= 0 which is always true because hDel returns 0 or more;
change the comparison to > 0 so the method returns true only when a field was
actually removed (i.e., replace the >= 0 check with > 0 on the hDel result in
delete()).

120-172: A new Redis TCP connection is opened for every single operation.

createRedisClient() performs DNS resolution, TCP connect, optional auth, and database select on every method call. For a caching layer meant to reduce latency, this connection overhead likely negates the benefit over a simple DB query.

Consider caching the Redis instance as a class property (lazy-initialized on first use), or accepting an already-connected Redis instance via the constructor.

Sketch: lazy-initialize and reuse the Redis client
-readonly class RedisConfigurationStore implements ConfigurationStoreInterface
+class RedisConfigurationStore implements ConfigurationStoreInterface
 {
+    private ?Redis $redis = null;
+
     public function __construct(
         private readonly ConfigurationStorageSettings $settings,
     ) {
     }
 
     // ... (public methods stay the same, calling $this->getRedisClient())
 
-    private function createRedisClient(): Redis
+    private function getRedisClient(): Redis
     {
+        if ($this->redis !== null) {
+            return $this->redis;
+        }
+
         if (!extension_loaded('redis')) {
             throw new RuntimeException('...');
         }
         // ... connection logic ...
-        return $redis;
+        $this->redis = $redis;
+        return $this->redis;
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php`
around lines 120 - 172, createRedisClient() currently creates a new Redis
connection for every call (DNS, connect, auth, select), causing high overhead;
modify RedisConfigurationStore to cache and reuse a single Redis instance by
adding a private class property (e.g., $redisClient) and change
createRedisClient() to lazy-initialize that property on first call and return it
thereafter, or alternatively allow injection of an already-connected Redis
instance via the constructor and use that instead; ensure the existing
connect/auth/select logic runs only once when initializing $redisClient and
subsequent calls simply return the stored Redis object.
phpmyfaq/admin/assets/src/configuration/configuration.ts (1)

519-542: Consider unifying the error-message extraction pattern.

Both handleSendTestMail and handleTestRedisConnection have identical error-message fallback logic (lines 533–538 and 582–586). A small shared helper would reduce duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/admin/assets/src/configuration/configuration.ts` around lines 519 -
542, Both handleSendTestMail and handleTestRedisConnection repeat the same
error-message extraction logic; create a small shared helper (e.g.,
extractResponseErrorMessage(response: unknown): string) that encapsulates the
fallbacks (response.error || response.message || typeof response.success ===
'string' ? response.success : '' || default message) and use it in both
handleSendTestMail and handleTestRedisConnection to replace the duplicated
errorMessage construction and subsequent pushErrorNotification calls.
phpmyfaq/src/phpMyFAQ/Configuration/Storage/DatabaseConfigurationStore.php (1)

33-47: Consider parameterized queries instead of sprintf + escape().

The current approach of escaping user inputs and interpolating into SQL strings via sprintf works but is less robust than prepared statements. If the DatabaseDriver supports prepared statements, this would be a safer pattern. That said, this appears to be the existing codebase convention, so this is a low-priority improvement.

Also applies to: 64-78

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/DatabaseConfigurationStore.php`
around lines 33 - 47, Replace string interpolation with a parameterized prepared
statement in updateConfigValue: instead of building the SQL via sprintf and
escape in DatabaseConfigurationStore::updateConfigValue, use the
DatabaseDriver's prepare/execute (or equivalent prepareQuery/bind/execute) API
to bind the config_value and config_name parameters; ensure you still include
the table name via Database::getTablePrefix() and $this->tableName (these cannot
be parameterized) and execute the prepared statement, returning the boolean
result. Apply the same change pattern to the other similar method(s) around the
64-78 region that build SQL with sprintf/escape so all updates use prepared
statements and bound parameters.
phpmyfaq/src/phpMyFAQ/Configuration/Storage/HybridConfigurationStore.php (2)

59-88: fetchAll: double resolveRedisStore() call creates two independent Redis connections.

When Redis is enabled but the cache is empty (cold start), the flow resolves a Redis store at line 61 (opens connection 1, queries, gets empty), then resolves again at line 77 (opens connection 2, warms). The second call could reuse the store from the first.

Proposed fix
     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(),
                 ]);
+                $redisStore = null; // skip warming on fetch failure
             }
         }
 
         $rows = $this->databaseConfigurationStore->fetchAll();
 
-        if ($redisStore !== null && $rows !== []) {
+        if ($redisStore !== null && $rows !== []) {
             try {
                 $redisStore->warmFromRows($rows);
             } catch (RuntimeException $exception) {

The main improvement here would come from the caching suggestion above. But even without it, if the first resolveRedisStore() call already returned a store, you already reuse $redisStore for warming — which is good. The issue is that RedisConfigurationStore::createRedisClient() creates a new connection per method call internally.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/HybridConfigurationStore.php`
around lines 59 - 88, fetchAll() is calling resolveRedisStore() twice which
causes RedisConfigurationStore::createRedisClient() to open two separate Redis
connections; change the implementation so the same Redis client/connection is
reused instead of recreated — either cache the resolved store/client inside
HybridConfigurationStore (store the first resolveRedisStore() result in a
property and reuse it for warming), or modify RedisConfigurationStore to lazily
instantiate and reuse a single client (e.g., keep a private $client and return
it from createRedisClient()), ensuring resolveRedisStore(), fetchAll(), and
RedisConfigurationStore::createRedisClient() use that single shared connection.

157-165: New Redis connection created on every single operation.

resolveRedisStore() instantiates a new RedisConfigurationStore per call, and each method in RedisConfigurationStore calls createRedisClient() which opens a fresh TCP/socket connection. For fetchAll(), this is called twice (lines 61 and 77), doubling the overhead.

On hot paths (e.g., every page load reading configuration), this means a DB query to resolve settings + a new Redis TCP connection per operation, which undermines the caching benefit.

Consider caching the resolved RedisConfigurationStore instance (and its underlying Redis connection) for the lifetime of the request, while still allowing the enabled flag to be re-evaluated if needed.

Sketch: cache the resolved store per request
 readonly class HybridConfigurationStore implements ConfigurationStoreInterface
 {
+    private ?RedisConfigurationStore $cachedRedisStore = null;
+    private bool $redisStoreResolved = false;
+
     // ...
 
     private function resolveRedisStore(): ?RedisConfigurationStore
     {
+        if ($this->redisStoreResolved) {
+            return $this->cachedRedisStore;
+        }
+
         $settings = $this->settingsResolver->resolve();
+        $this->redisStoreResolved = true;
+
         if (!$settings->enabled) {
+            $this->cachedRedisStore = null;
             return null;
         }
 
-        return new RedisConfigurationStore($settings);
+        $this->cachedRedisStore = new RedisConfigurationStore($settings);
+        return $this->cachedRedisStore;
     }
 }

Note: this would also require removing the readonly modifier from the class, since mutable properties are introduced.

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docker-compose.yml`:
- Around line 86-90: The compose file currently uses depends_on with "redis:
condition: service_healthy" (see the depends_on blocks referencing redis at the
shown diffs and repeats around the other locations) which forces services to
wait for Redis health and blocks startup when Redis is intentionally unused;
update the docker-compose.yml to either make Redis optional or document the
expectation: replace the strict health condition with a looser dependency (e.g.,
remove the condition or use service_started) on the depends_on entries that
reference "redis", and/or add a clear comment next to the redis service
definition explaining that Redis must be running for the dev stack (so
contributors know it’s required), applying the same change/comments to the other
repeated blocks (the other depends_on occurrences noted).

In `@docs/administration.md`:
- Around line 799-816: The "Redis-backed configuration storage" section titled
"5.6.5 Redis-backed configuration storage" is out of order and duplicates an
existing 5.6.5; move the entire section (the header and its content) so it
follows 5.6.4, then renumber that header to 5.6.5; after doing so increment the
existing "Elasticsearch configuration" and the subsequent two sections currently
numbered 5.6.5–5.6.7 to 5.6.6–5.6.8 respectively to avoid duplicate numbers and
preserve ordering.

In `@mkdocs.yml`:
- Line 18: The mkdocs.yml navigation references a missing page
'redis-configuration-storage.md' which will break the site build; either add a
new docs/redis-configuration-storage.md file with the Redis configuration
storage documentation or remove the navigation entry for
'redis-configuration-storage.md' from mkdocs.yml (the unique symbol to locate is
the string 'redis-configuration-storage.md' in the nav list).

In `@phpmyfaq/admin/assets/src/configuration/configuration.ts`:
- Around line 598-612: The current setupRedisTestButtonState creates a new
updateState closure on each call so
redisDsnInput.removeEventListener(updateState) never removes prior listeners;
refactor by declaring a module-scoped variable (e.g., let redisInputListener:
EventListener | null) and assign the listener function to it, then when
setupRedisTestButtonState runs check if redisInputListener exists and call
redisDsnInput.removeEventListener('input', redisInputListener) before
creating/assigning a new listener and adding it with addEventListener; keep the
existing logic for disabling the button using redisDsnInput.value.trim() === ''
and call the listener once to initialize.

In `@phpmyfaq/assets/templates/admin/configuration/macros.twig`:
- Line 83: The template assigns action via a ternary to '' when key is unknown
(variable action in macros.twig), which leaves buttons inert; update the logic
in the macros where action is set (the line that checks key ==
'mail.sendTestEmail' and key == 'storage.testRedisConnection') to explicitly
fail or surface a warning for unknown keys instead of returning an empty
string—e.g., add an {% else %} branch that throws a template error or sets
action to a sentinel like 'unsupported-action' and renders a visible warning
comment so future additions are noticed and buttons don't silently do nothing.

In `@phpmyfaq/assets/templates/admin/configuration/main.twig`:
- Around line 128-133: The new <li> for the storage tab (the element with
data-config-label="{{ 'storageControlCenter' | translate }}" and icon class
"bi-database-gear") is tagged data-config-group="communication" but placed after
the Integrations group items, causing DOM/grouping mismatch; fix by either
moving that <li> to appear before the Integrations group header so it stays in
the "communication" group, or change its data-config-group value to
"integrations" so it logically matches its current DOM position and
Redis/integration semantics.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php`:
- Around line 159-169: The calls to $redis->auth(...) and
$redis->select($database) in RedisConfigurationStore are not checked for
failure; update the code around the auth and select calls to verify their
boolean return values and handle failures (e.g., throw a descriptive exception
or log and abort initialization) so auth failures or invalid DB selection aren’t
silent. Specifically, after calling $redis->auth($parsedUrl['pass']) and
$redis->select($database) in the RedisConfigurationStore class, check for ===
false and raise/return an error with context (include the attempted database
index or note authentication failed) so callers know initialization failed.

In `@phpmyfaq/translations/language_en.php`:
- Around line 1694-1698: The description for $LANG_CONF['storage.redisDsn']
contains a Docker-specific hint "(Docker service name)" that may confuse
non-Docker users; update the human-facing string to remove or generalize the
Docker wording—e.g., keep example DSNs but remove the parenthetical or replace
with a neutral phrase like "service hostname"—so the admin UI shows a clear,
implementation-agnostic hint for Redis DSN users.

In `@tests/phpMyFAQ/Configuration/ConfigurationStorageSettingsResolverTest.php`:
- Around line 1-3: Add a strict types declaration at the top of the test file:
insert declare(strict_types=1); immediately after the opening <?php tag and
before the namespace phpMyFAQ\Configuration (affecting the
ConfigurationStorageSettingsResolverTest class in this file) so the file
complies with PER Coding Style 3.0.

In `@tests/phpMyFAQ/Configuration/HybridConfigurationStoreTest.php`:
- Around line 1-3: Add the PHP strict types declaration to this test file by
inserting declare(strict_types=1); immediately after the opening <?php tag to
comply with PER Coding Style 3.0; update the HybridConfigurationStoreTest file
(class HybridConfigurationStoreTest in namespace phpMyFAQ\Configuration) so the
file begins with <?php followed by declare(strict_types=1); and then the
namespace declaration.

---

Outside diff comments:
In `@phpmyfaq/src/phpMyFAQ/Configuration/ConfigurationRepository.php`:
- Around line 92-104: The current updateFaqDataContent(string $oldContent,
string $newContent) updates rows by matching content which can update multiple
FAQs sharing identical content; change it to target a single FAQ by ID (e.g.,
add/replace with updateFaqDataContentById(int $faqId, string $newContent) or add
a $faqId parameter) and update the SQL to use the faq_id in the WHERE clause
instead of content. Ensure you escape or preferably parameterize the newContent
and faqId using the existing coreConfiguration->getDb() methods, update call
sites such as replaceMainReferenceUrl() to pass the specific FAQ id, and remove
the old content-based bulk-update usage.

In `@phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php`:
- Around line 39-42: Update the migration description returned by
getDescription() in Migration420Alpha so it lists the four new storage.*
configuration entries added by this migration; locate the getDescription()
method in the Migration420Alpha class and append a clear mention of the
storage.* config changes (e.g. "storage.* config entries") to the existing
comma-separated description string so the full scope — Admin log hash columns,
custom pages, chat messages, translation config, API rate limiting, queue jobs,
mail provider config, API keys, OAuth2 tables, and the storage.* config entries
— is reflected.

---

Nitpick comments:
In `@phpmyfaq/admin/assets/src/api/configuration.ts`:
- Around line 157-173: Both sendTestMail and testRedisConnection send JSON
bodies but omit the Content-Type header; update their fetchJson calls (in
functions sendTestMail and testRedisConnection) to include headers: {
'Content-Type': 'application/json' } so the request properly advertises JSON
payloads (ensure you merge with any existing headers option rather than
replacing other options).

In `@phpmyfaq/admin/assets/src/configuration/configuration.test.ts`:
- Around line 229-281: The tests only cover success paths for handleSendTestMail
and handleTestRedisConnection; add negative-path tests: for handleSendTestMail
add a test that leaves out the pmf-csrf-token input, asserts sendTestMail is not
called and that the proper error/notification flow runs; for
handleTestRedisConnection add tests that omit edit[storage.redisDsn] (assert
testRedisConnection not called) and simulate API failure by mocking
testRedisConnection to reject or resolve with success: false and assert the
error/notification handling and button state; reference the functions
handleSendTestMail, handleTestRedisConnection, sendTestMail, testRedisConnection
and the button id btn-phpmyfaq-storage-testRedisConnection when adding these
tests.

In `@phpmyfaq/admin/assets/src/configuration/configuration.ts`:
- Around line 519-542: Both handleSendTestMail and handleTestRedisConnection
repeat the same error-message extraction logic; create a small shared helper
(e.g., extractResponseErrorMessage(response: unknown): string) that encapsulates
the fallbacks (response.error || response.message || typeof response.success ===
'string' ? response.success : '' || default message) and use it in both
handleSendTestMail and handleTestRedisConnection to replace the duplicated
errorMessage construction and subsequent pushErrorNotification calls.

In `@phpmyfaq/assets/templates/admin/configuration/macros.twig`:
- Around line 82-94: Rename the misleading macro sendTestMailButton to a generic
name (e.g., configActionButton) and update all call sites that reference it
(notably in main.twig and tab-list.twig) to use the new macro name; ensure the
macro signature ({% macro sendTestMailButton(label, key, description) %}) is
renamed consistently (e.g., {% macro configActionButton(label, key, description)
%}) and that the internal usage of key (id "btn-phpmyfaq-{{ key|replace({'.':
'-'}) }}" and data attribute data-pmf-config-action="{{ action }}") remains
unchanged so behavior is preserved.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStoreInterface.php`:
- Around line 26-29: The docblock for ConfigurationStoreInterface::fetchAll() is
too generic; update its `@return` annotation from "array<int, object>" to
"array<int, \stdClass>" so static analyzers and IDEs know the fetched DB rows
are stdClass instances; locate the fetchAll method in the
ConfigurationStoreInterface and replace the return type in the PHPDoc
accordingly while preserving the method signature.
- Around line 24-35: The method parameter names are inconsistent:
updateConfigValue uses $key while insert/delete use $name and renameKey uses
$currentKey/$newKey; standardize to a single term (use $key) across the
interface. Update insert(string $name, string $value): bool to insert(string
$key, string $value): bool, delete(string $name): bool to delete(string $key):
bool, and renameKey(string $currentKey, string $newKey): bool to
renameKey(string $key, string $newKey): bool (or rename to renameKey(string
$oldKey, string $newKey): bool if you prefer old/new wording), and adjust any
associated phpdoc types/comments to reference $key consistently; keep
updateConfigValue(string $key, string $value): bool and fetchAll(): array
unchanged.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/DatabaseConfigurationStore.php`:
- Around line 33-47: Replace string interpolation with a parameterized prepared
statement in updateConfigValue: instead of building the SQL via sprintf and
escape in DatabaseConfigurationStore::updateConfigValue, use the
DatabaseDriver's prepare/execute (or equivalent prepareQuery/bind/execute) API
to bind the config_value and config_name parameters; ensure you still include
the table name via Database::getTablePrefix() and $this->tableName (these cannot
be parameterized) and execute the prepared statement, returning the boolean
result. Apply the same change pattern to the other similar method(s) around the
64-78 region that build SQL with sprintf/escape so all updates use prepared
statements and bound parameters.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/HybridConfigurationStore.php`:
- Around line 59-88: fetchAll() is calling resolveRedisStore() twice which
causes RedisConfigurationStore::createRedisClient() to open two separate Redis
connections; change the implementation so the same Redis client/connection is
reused instead of recreated — either cache the resolved store/client inside
HybridConfigurationStore (store the first resolveRedisStore() result in a
property and reuse it for warming), or modify RedisConfigurationStore to lazily
instantiate and reuse a single client (e.g., keep a private $client and return
it from createRedisClient()), ensuring resolveRedisStore(), fetchAll(), and
RedisConfigurationStore::createRedisClient() use that single shared connection.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php`:
- Around line 66-70: The delete method in RedisConfigurationStore::delete uses
redis->hDel(... ) >= 0 which is always true because hDel returns 0 or more;
change the comparison to > 0 so the method returns true only when a field was
actually removed (i.e., replace the >= 0 check with > 0 on the hDel result in
delete()).
- Around line 120-172: createRedisClient() currently creates a new Redis
connection for every call (DNS, connect, auth, select), causing high overhead;
modify RedisConfigurationStore to cache and reuse a single Redis instance by
adding a private class property (e.g., $redisClient) and change
createRedisClient() to lazy-initialize that property on first call and return it
thereafter, or alternatively allow injection of an already-connected Redis
instance via the constructor and use that instead; ensure the existing
connect/auth/select logic runs only once when initializing $redisClient and
subsequent calls simply return the stored Redis object.

In
`@phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationController.php`:
- Around line 122-125: The success response in ConfigurationController currently
returns a hardcoded English string ("Redis connection successful."); replace
that literal with the i18n helper used elsewhere (e.g.
Translation::get('healthCheckOkay') or a more specific translation key if
available) so the JSON response uses Translation::get(...) like
activateMaintenanceMode does, updating the 'message' value in the return
$this->json(...) statement to call Translation::get(...) instead of the raw
string.
- Around line 113-117: The code calls RedisSessionHandler::validateConnection
with $redisDsn that may be an empty string; add an explicit guard in
ConfigurationController before calling validateConnection to check if
trim($redisDsn) === '' and short-circuit (return or set a clear error/validation
response) so you don't delegate an empty DSN to RedisSessionHandler; reference
the variables $redisDsn and $timeout and the method
RedisSessionHandler::validateConnection in your change.

In
`@phpmyfaq/src/phpMyFAQ/Controller/Administration/SystemInformationController.php`:
- Around line 100-101: The null-coalescing fallbacks are redundant for the
'Elasticsearch Version' and 'OpenSearch Version' entries because $esInformation
is always assigned (via the try/catch and the explicit negative if) and
$openSearchInformation is initialized to '' before use; remove the "?? 'n/a'"
from the array entries so they read simply 'Elasticsearch Version' =>
$esInformation and 'OpenSearch Version' => $openSearchInformation in
SystemInformationController (class/method where these variables are prepared).

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 290628f and 83636ba.

📒 Files selected for processing (32)
  • CHANGELOG.md
  • docker-compose.yml
  • docs/administration.md
  • docs/installation.md
  • mkdocs.yml
  • phpmyfaq/admin/assets/src/api/configuration.test.ts
  • phpmyfaq/admin/assets/src/api/configuration.ts
  • phpmyfaq/admin/assets/src/api/fetch-wrapper.test.ts
  • phpmyfaq/admin/assets/src/api/fetch-wrapper.ts
  • phpmyfaq/admin/assets/src/configuration/configuration.test.ts
  • phpmyfaq/admin/assets/src/configuration/configuration.ts
  • phpmyfaq/assets/templates/admin/configuration/macros.twig
  • phpmyfaq/assets/templates/admin/configuration/main.twig
  • phpmyfaq/assets/templates/admin/configuration/tab-list.twig
  • phpmyfaq/src/phpMyFAQ/Configuration/ConfigurationRepository.php
  • phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStorageSettings.php
  • phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStorageSettingsResolver.php
  • phpmyfaq/src/phpMyFAQ/Configuration/Storage/ConfigurationStoreInterface.php
  • phpmyfaq/src/phpMyFAQ/Configuration/Storage/DatabaseConfigurationStore.php
  • phpmyfaq/src/phpMyFAQ/Configuration/Storage/HybridConfigurationStore.php
  • phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationController.php
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/SystemInformationController.php
  • phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php
  • phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php
  • phpmyfaq/translations/language_en.php
  • tests/phpMyFAQ/Auth/OAuth2/AuthorizationServerTest.php
  • tests/phpMyFAQ/Configuration/ConfigurationStorageSettingsResolverTest.php
  • tests/phpMyFAQ/Configuration/HybridConfigurationStoreTest.php
  • tests/phpMyFAQ/Controller/Administration/Api/ConfigurationControllerTest.php
  • tests/phpMyFAQ/Setup/Installation/DefaultDataSeederTest.php
  • tests/phpMyFAQ/Setup/Migration/MigrationRegistryTest.php
💤 Files with no reviewable changes (1)
  • tests/phpMyFAQ/Auth/OAuth2/AuthorizationServerTest.php

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

♻️ Duplicate comments (4)
phpmyfaq/admin/assets/src/configuration/configuration.ts (1)

598-618: setupRedisTestButtonState listener management correctly addressed.

The module-scoped redisInputListener variable is properly used to remove the prior listener before registering a new one, and the initial state is seeded by calling the listener once. This resolves the listener-accumulation bug from the previous review.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/admin/assets/src/configuration/configuration.ts` around lines 598 -
618, The listener-accumulation bug has been addressed by using the module-scoped
variable redisInputListener in setupRedisTestButtonState to remove the previous
listener before adding a new one and by calling redisInputListener() to seed the
initial state; leave the implementation of setupRedisTestButtonState and the
redisInputListener handling as-is (no further changes required).
phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php (1)

172-176: Previous concern about unchecked auth() and select() has been addressed.

Both auth() and select() return values are now properly checked with exceptions thrown on failure.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php`
around lines 172 - 176, The RedisConfigurationStore previously did not check
Redis auth/select return values; update the RedisConfigurationStore class so
that in the block using $redis->auth($parsedUrl['pass']) and
$redis->select($dbIndex) you validate the return values and throw a
RuntimeException on failure (e.g., "Redis authentication failed for
configuration storage." and a similar message for select) to fail fast and
surface errors from authentication/selection.
tests/phpMyFAQ/Configuration/HybridConfigurationStoreTest.php (1)

1-4: Previous concern about missing declare(strict_types=1) has been addressed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/phpMyFAQ/Configuration/HybridConfigurationStoreTest.php` around lines 1
- 4, The previous issue about adding declare(strict_types=1) has been resolved
in tests/phpMyFAQ/Configuration/HybridConfigurationStoreTest.php; no further
changes are required—keep the declared strict types and proceed with approval of
the test file (HybridConfigurationStoreTest).
tests/phpMyFAQ/Configuration/ConfigurationStorageSettingsResolverTest.php (1)

1-4: Previous concern about missing declare(strict_types=1) has been addressed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/phpMyFAQ/Configuration/ConfigurationStorageSettingsResolverTest.php`
around lines 1 - 4, The file now includes the required declare(strict_types=1)
directive at the top (the declare statement), so no further changes are needed;
leave the declare(strict_types=1) line as-is and proceed to approve the change.
🧹 Nitpick comments (5)
phpmyfaq/admin/assets/src/configuration/configuration.ts (3)

497-517: registerConfigurationActionButtons is needlessly async.

The function body has no await and the forEach callbacks are synchronous. The async keyword and the await at the two call sites (lines 144, 147) are no-ops. Remove both to keep the signature honest.

♻️ Proposed fix
-const registerConfigurationActionButtons = async (): Promise<void> => {
+const registerConfigurationActionButtons = (): void => {
-          case '#mail':
-            await handleSMTPPasswordToggle();
-            await handleMailProvider();
-            await registerConfigurationActionButtons();
+          case '#mail':
+            await handleSMTPPasswordToggle();
+            await handleMailProvider();
+            registerConfigurationActionButtons();
             break;
-          case '#storage':
-            await registerConfigurationActionButtons();
-            setupRedisTestButtonState();
+          case '#storage':
+            registerConfigurationActionButtons();
+            setupRedisTestButtonState();
             break;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/admin/assets/src/configuration/configuration.ts` around lines 497 -
517, The function registerConfigurationActionButtons is declared async but
contains no awaits; remove the async modifier from its declaration and change
its return type to void (or inferred), and then remove the unnecessary await
operators at its call sites (where registerConfigurationActionButtons() is
invoked) so callers call it directly without awaiting; update any related type
annotations if present to reflect the synchronous signature.

560-595: Add a client-side AbortController timeout as a safety net.

timeoutValue is forwarded to the server for it to apply when connecting to Redis, but there is no client-side abort. If the server itself hangs (e.g., TCP connection never established), the fetch blocks indefinitely and the button stays permanently disabled with the spinner active.

Wrap the call with an AbortController using a slightly longer deadline than timeoutValue (e.g., timeoutValue + 2 seconds) to ensure the UI always recovers.

♻️ Proposed refactor
+  const controller = new AbortController();
+  const abortTimer = setTimeout(() => controller.abort(), (timeoutValue + 2) * 1000);
+
   try {
     const response = (await testRedisConnection(
       csrfInput.value,
       redisDsnInput.value.trim(),
       timeoutValue
     )) as unknown as Response;
+    clearTimeout(abortTimer);
     ...
   } catch (error) {
+    clearTimeout(abortTimer);
     pushErrorNotification(error instanceof Error ? error.message : 'Redis connection test failed.');
   } finally {

You will also need to pass signal: controller.signal down through testRedisConnection into the underlying fetchJson call.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/admin/assets/src/configuration/configuration.ts` around lines 560 -
595, The client currently awaits testRedisConnection(...) without a client-side
timeout, so a hanging server can leave the spinner/button disabled; create an
AbortController, set a timeout using setTimeout to call controller.abort() after
(timeoutValue + 2) seconds, pass controller.signal into testRedisConnection (and
ensure testRedisConnection forwards it into fetchJson), and clear the timer on
success/failure so that the finally block re-enables the button and restores
innerHTML; keep using csrfInput.value and redisDsnInput.value.trim() and call
pushNotification / pushErrorNotification as before, but ensure aborted fetches
are handled in the catch to show an error and restore the UI.

533-537: Redundant ternary in error fallback — same issue exists in handleTestRedisConnection (lines 582–586).

Response.success is typed as a non-optional string, so typeof response.success === 'string' is always true. By the time this branch is reached, response.success was already falsy (the early-return guard on line 528 would have fired otherwise), so the third operand always resolves to '' and never contributes a real message. Remove the ternary.

♻️ Proposed fix (applies identically to lines 582–586)
     const errorMessage =
       response.error ||
       response.message ||
-      (typeof response.success === 'string' ? response.success : '') ||
       'Sending test email failed.';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/admin/assets/src/configuration/configuration.ts` around lines 533 -
537, The fallback building of errorMessage uses a redundant ternary "(typeof
response.success === 'string' ? response.success : '')" which always evaluates
the same and never contributes; in the error construction (variable
errorMessage) replace that ternary with a simple response.success || '' (or just
response.success) and apply the same change in the other handler
(handleTestRedisConnection) where the identical ternary appears so the fallback
chain becomes response.error || response.message || response.success || 'Sending
test email failed.' (and similarly for the Redis test message).
phpmyfaq/src/phpMyFAQ/Configuration/ConfigurationRepository.php (1)

92-105: SQL injection surface via sprintf + escape() — pre-existing pattern, but worth noting.

The sprintf approach with '%s' relies entirely on escape() being correct for all edge cases (e.g., multi-byte attacks). This is a pre-existing pattern across the codebase (DatabaseConfigurationStore uses the same approach), but parameterized queries would be a safer long-term direction.

Not blocking — just flagging for awareness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/src/phpMyFAQ/Configuration/ConfigurationRepository.php` around lines
92 - 105, The updateFaqDataContentById method builds an SQL string via sprintf
and escape() which can be fragile against edge-case injection; replace this with
a parameterized/prepared statement using the DB API exposed by
$this->coreConfiguration->getDb() (use prepare on the DB connection, bind the
$newContent, $faqId and $lang values, and execute) instead of injecting values
into the query string; keep Database::getTablePrefix() for the table name but do
not inject user data into the SQL text—use prepared parameters for content and
lang (and faqId) and return the boolean result of the prepared execution.
phpmyfaq/src/phpMyFAQ/Configuration.php (1)

534-539: Return value of updateFaqDataContentById() is silently discarded.

If any individual FAQ update fails, the method still returns true on line 543. Consider accumulating failures or short-circuiting on error.

♻️ Proposed fix — track failures
+        $success = true;
         foreach ($contentItems as $contentItem) {
             if (str_contains((string) $contentItem->content, $oldUrl)) {
                 $newContent = str_replace($oldUrl, $newUrl, $contentItem->content);
-                $this->configurationRepository->updateFaqDataContentById(
+                if (!$this->configurationRepository->updateFaqDataContentById(
                     (int) $contentItem->id,
                     (string) $contentItem->lang,
                     $newContent,
-                );
+                )) {
+                    $success = false;
+                }
             }
         }

-        return true;
+        return $success;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/src/phpMyFAQ/Configuration.php` around lines 534 - 539, The call to
updateFaqDataContentById(...) currently ignores its return value; change the
code in the loop that processes $contentItem so you capture the boolean result
from updateFaqDataContentById and either accumulate failures (e.g. $success =
$success && $result) or short-circuit on first false (return false), and ensure
the enclosing method (the function that contains this loop) returns a
consolidated boolean instead of always true; reference updateFaqDataContentById,
$contentItem, $newContent and the enclosing method name to locate where to check
the result and update the final return behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/administration.md`:
- Line 865: Update the two occurrences of the phrase "Elasticsearch relevant"
(and the similar "OpenSearch relevant") in the documentation so the compound
adjectives are hyphenated when used as pre-noun modifiers; replace
"Elasticsearch relevant usage data" with "Elasticsearch-relevant usage data" and
likewise change "OpenSearch relevant" to "OpenSearch-relevant" to follow
compound-adjective hyphenation rules.
- Around line 843-859: The link target redis-configuration-storage.md referenced
as "Redis-Backed Configuration Storage" is missing; either add a new
documentation page with that filename containing the Redis-backed configuration
setup and troubleshooting content and ensure it is included in the docs index,
or update the link in the Administration section to point to an existing,
correct document (or remove the link) so the reference in the "5.6.5
Redis-backed configuration storage" subsection resolves correctly.

In `@docs/installation.md`:
- Around line 808-815: Under the "DSN Examples" section replace the 4-space
indented nested list items that are currently being parsed as indented code with
proper fenced code blocks: for each example (Docker Compose service name, Local
host, Unix socket) move the descriptive line (e.g., "Docker Compose service
name:") as plain text and follow it with a triple-backtick fenced block
containing the DSN (e.g., tcp://redis:6379?database=1), ensuring each DSN is
wrapped in its own fenced block; update the "DSN Examples" subsection
accordingly to remove the 4-space indentation and eliminate the MD046 warning.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php`:
- Around line 32-36: The class currently calls createRedisClient() inside each
public method (e.g., updateConfigValue(), fetchAll(), warmFromRows(),
deleteConfigValue(), etc.), causing a fresh Redis connect/auth/select per call;
change to a single lazily-initialized client accessor (e.g., add a private
getRedisClient() that caches the client instance via a Closure stored in a
readonly property or by making the client property mutable if you drop readonly)
and have all public methods (updateConfigValue, fetchAll, warmFromRows,
deleteConfigValue, getConfigValue, setConfigValue, etc.) use getRedisClient()
instead of createRedisClient() so connections are reused; ensure
getRedisClient() calls createRedisClient() only once and returns the cached
client for subsequent calls.
- Around line 72-87: The renameKey method can leave both keys present if hDel
fails; update renameKey to check the return value of
$redis->hDel($this->getHashKey(), $currentKey) and handle failures: if hDel
returns false or 0, attempt to rollback by removing the newly set $newKey (or
restoring the original $currentKey) and then return false (or throw/propagate an
error as your error strategy mandates). Use the existing createRedisClient(),
getHashKey(), renameKey, hSet and hGet symbols to locate the logic to add this
failure check and rollback.

In
`@phpmyfaq/src/phpMyFAQ/Controller/Administration/SystemInformationController.php`:
- Around line 81-109: Replace the manual defaulting block for
redisDsn/redisPrefix/redisTimeout with the ConfigurationStorageSettingsResolver:
instantiate ConfigurationStorageSettingsResolver using $this->configuration,
call its resolve() (or equivalent) to obtain a ConfigurationStorageSettings
instance, then pass that settings object into RedisConfigurationStore and call
getInstalledRedisVersion(); also add imports for
ConfigurationStorageSettingsResolver (and DatabaseConfigurationStore if used
elsewhere) and remove the duplicated default logic and its try/catch placement
so the resolver provides canonical defaults.

---

Duplicate comments:
In `@phpmyfaq/admin/assets/src/configuration/configuration.ts`:
- Around line 598-618: The listener-accumulation bug has been addressed by using
the module-scoped variable redisInputListener in setupRedisTestButtonState to
remove the previous listener before adding a new one and by calling
redisInputListener() to seed the initial state; leave the implementation of
setupRedisTestButtonState and the redisInputListener handling as-is (no further
changes required).

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php`:
- Around line 172-176: The RedisConfigurationStore previously did not check
Redis auth/select return values; update the RedisConfigurationStore class so
that in the block using $redis->auth($parsedUrl['pass']) and
$redis->select($dbIndex) you validate the return values and throw a
RuntimeException on failure (e.g., "Redis authentication failed for
configuration storage." and a similar message for select) to fail fast and
surface errors from authentication/selection.

In `@tests/phpMyFAQ/Configuration/ConfigurationStorageSettingsResolverTest.php`:
- Around line 1-4: The file now includes the required declare(strict_types=1)
directive at the top (the declare statement), so no further changes are needed;
leave the declare(strict_types=1) line as-is and proceed to approve the change.

In `@tests/phpMyFAQ/Configuration/HybridConfigurationStoreTest.php`:
- Around line 1-4: The previous issue about adding declare(strict_types=1) has
been resolved in tests/phpMyFAQ/Configuration/HybridConfigurationStoreTest.php;
no further changes are required—keep the declared strict types and proceed with
approval of the test file (HybridConfigurationStoreTest).

---

Nitpick comments:
In `@phpmyfaq/admin/assets/src/configuration/configuration.ts`:
- Around line 497-517: The function registerConfigurationActionButtons is
declared async but contains no awaits; remove the async modifier from its
declaration and change its return type to void (or inferred), and then remove
the unnecessary await operators at its call sites (where
registerConfigurationActionButtons() is invoked) so callers call it directly
without awaiting; update any related type annotations if present to reflect the
synchronous signature.
- Around line 560-595: The client currently awaits testRedisConnection(...)
without a client-side timeout, so a hanging server can leave the spinner/button
disabled; create an AbortController, set a timeout using setTimeout to call
controller.abort() after (timeoutValue + 2) seconds, pass controller.signal into
testRedisConnection (and ensure testRedisConnection forwards it into fetchJson),
and clear the timer on success/failure so that the finally block re-enables the
button and restores innerHTML; keep using csrfInput.value and
redisDsnInput.value.trim() and call pushNotification / pushErrorNotification as
before, but ensure aborted fetches are handled in the catch to show an error and
restore the UI.
- Around line 533-537: The fallback building of errorMessage uses a redundant
ternary "(typeof response.success === 'string' ? response.success : '')" which
always evaluates the same and never contributes; in the error construction
(variable errorMessage) replace that ternary with a simple response.success ||
'' (or just response.success) and apply the same change in the other handler
(handleTestRedisConnection) where the identical ternary appears so the fallback
chain becomes response.error || response.message || response.success || 'Sending
test email failed.' (and similarly for the Redis test message).

In `@phpmyfaq/src/phpMyFAQ/Configuration.php`:
- Around line 534-539: The call to updateFaqDataContentById(...) currently
ignores its return value; change the code in the loop that processes
$contentItem so you capture the boolean result from updateFaqDataContentById and
either accumulate failures (e.g. $success = $success && $result) or
short-circuit on first false (return false), and ensure the enclosing method
(the function that contains this loop) returns a consolidated boolean instead of
always true; reference updateFaqDataContentById, $contentItem, $newContent and
the enclosing method name to locate where to check the result and update the
final return behavior.

In `@phpmyfaq/src/phpMyFAQ/Configuration/ConfigurationRepository.php`:
- Around line 92-105: The updateFaqDataContentById method builds an SQL string
via sprintf and escape() which can be fragile against edge-case injection;
replace this with a parameterized/prepared statement using the DB API exposed by
$this->coreConfiguration->getDb() (use prepare on the DB connection, bind the
$newContent, $faqId and $lang values, and execute) instead of injecting values
into the query string; keep Database::getTablePrefix() for the table name but do
not inject user data into the SQL text—use prepared parameters for content and
lang (and faqId) and return the boolean result of the prepared execution.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 83636ba and 4cd5915.

📒 Files selected for processing (13)
  • docker-compose.yml
  • docs/administration.md
  • docs/installation.md
  • phpmyfaq/admin/assets/src/configuration/configuration.ts
  • phpmyfaq/assets/templates/admin/configuration/macros.twig
  • phpmyfaq/assets/templates/admin/configuration/main.twig
  • phpmyfaq/src/phpMyFAQ/Configuration.php
  • phpmyfaq/src/phpMyFAQ/Configuration/ConfigurationRepository.php
  • phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/SystemInformationController.php
  • phpmyfaq/translations/language_en.php
  • tests/phpMyFAQ/Configuration/ConfigurationStorageSettingsResolverTest.php
  • tests/phpMyFAQ/Configuration/HybridConfigurationStoreTest.php
🚧 Files skipped from review as they are similar to previous changes (4)
  • phpmyfaq/translations/language_en.php
  • docker-compose.yml
  • phpmyfaq/assets/templates/admin/configuration/macros.twig
  • phpmyfaq/assets/templates/admin/configuration/main.twig

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
docs/installation.md (1)

788-788: Minor heading casing inconsistency between the two docs.

administration.md uses sentence case ("Redis-backed configuration storage") while this file uses title case ("Redis-Backed Configuration Storage"). Aligning to one style across the docs would improve consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/installation.md` at line 788, Change the heading "Redis-Backed
Configuration Storage" in this file to sentence case "Redis-backed configuration
storage" to match administration.md; update any internal references/TOC entries
or links that rely on the exact heading text/anchor so they still resolve
correctly (search for the heading string "Redis-Backed Configuration Storage"
and replace it with "Redis-backed configuration storage", and verify any anchors
or cross-doc links).
phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php (2)

193-197: Path-based database index (redis://host/1) is silently ignored — only query-string format is supported.

The conventional redis://host:6379/1 path format for selecting database 1 is ignored; the code only reads ?database= or ?db= from the query string. Users following the standard convention will silently land on database 0. Consider supporting or at minimum documenting the supported DSN format.

♻️ Proposed fix — fall back to path for database index
     $database = 0;
     if (isset($parsedUrl['query'])) {
         parse_str($parsedUrl['query'], $queryParams);
         $database = (int) ($queryParams['database'] ?? $queryParams['db'] ?? 0);
+    } elseif (isset($parsedUrl['path']) && $parsedUrl['path'] !== '' && $parsedUrl['path'] !== '/') {
+        $database = (int) ltrim($parsedUrl['path'], '/');
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php`
around lines 193 - 197, The code only reads database index from query params
(via parse_str into $queryParams) and ignores a path-style index like
redis://host/1; update the logic in RedisConfigurationStore (where $parsedUrl is
inspected and $database is set) to, after checking query params, fall back to
parsing $parsedUrl['path'] (trim leading '/' and cast to int) when no
database/query param is present; ensure you handle empty/invalid path values by
leaving $database as 0 and preserve existing behavior when query params
explicitly provide db/database.

62-66: insert() cannot distinguish a Redis error from "field already exists".

hSetNx returns false for both "field already exists" and a Redis error. The (bool) cast merges these two failure modes, so callers have no way to tell whether the insert was skipped or failed. This may be acceptable given the current interface contract, but callers relying on the return value for error detection may be misled.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php`
around lines 62 - 66, insert() currently casts hSetNx() to bool so it cannot
distinguish "field already exists" from a Redis error; update insert(string
$name, string $value) to disambiguate by either (A) checking the field first
with the Redis client (use hExists via
getRedisClient()->hExists($this->getHashKey(), $name)) and return false if it
exists, then call hSetNx and treat a false there as an error, or (B) wrap the
hSetNx call in a try/catch catching RedisException from
getRedisClient()->hSetNx($this->getHashKey(), $name, $value) and rethrow or
return a different signal; reference the insert method, getRedisClient(), hSetNx
and getHashKey() so you can locate and implement the chosen fix.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/installation.md`:
- Around line 812-826: Three fenced code blocks containing the DSNs
("tcp://redis:6379?database=1", "tcp://127.0.0.1:6379?database=1",
"unix:///var/run/redis/redis.sock?database=1") lack a language specifier which
triggers MD040; add a language token such as text or plaintext to each fenced
block header to silence MD040. Also address the MD046 warnings about indented vs
fenced style by updating the markdownlint configuration (change style to
"fenced" or "consistent") so these deliberate fenced blocks are allowed rather
than converting them to indented code blocks.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php`:
- Around line 111-132: warmFromRows currently only calls hMSet so
deleted/renamed config keys stay in Redis; modify warmFromRows (use
getRedisClient() and getHashKey()) to run a pipeline that first deletes the
existing hash (DEL <hashKey>) and then inserts the new fields (HMSET/HMSET
equivalent) so the replacement is atomic and avoids stale entries returned by
fetchAll(); keep the early-return behavior for empty $rows and ensure you still
return the boolean result of the pipeline operation.
- Around line 68-72: The delete() method currently treats a false return from
Redis::hDel as true due to PHP's comparison rules; change delete() (using
getRedisClient(), getHashKey(), and hDel) to explicitly detect a false return
(Redis error) and return false, and otherwise return whether the numeric result
is >=1 (or >0) to indicate a field was actually deleted (e.g. assign $res =
$redis->hDel(...); return $res !== false && $res > 0;).
- Around line 148-207: The createRedisClient() and all public methods that call
Redis (updateConfigValue(), fetchAll(), insert(), delete(), renameKey(),
getInstalledRedisVersion(), warmFromRows()) currently let phpredis throw
RedisException which leaks out; modify createRedisClient() to wrap all Redis
calls (connect, auth, select, etc.) in try/catch(RedisException $e) and rethrow
a RuntimeException with a clear message and pass $e as the previous exception,
and similarly wrap the Redis operations inside each listed public method in
try/catch to convert any RedisException into a RuntimeException (preserving the
original exception as the previous exception) so callers only ever receive
RuntimeException.

---

Nitpick comments:
In `@docs/installation.md`:
- Line 788: Change the heading "Redis-Backed Configuration Storage" in this file
to sentence case "Redis-backed configuration storage" to match
administration.md; update any internal references/TOC entries or links that rely
on the exact heading text/anchor so they still resolve correctly (search for the
heading string "Redis-Backed Configuration Storage" and replace it with
"Redis-backed configuration storage", and verify any anchors or cross-doc
links).

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php`:
- Around line 193-197: The code only reads database index from query params (via
parse_str into $queryParams) and ignores a path-style index like redis://host/1;
update the logic in RedisConfigurationStore (where $parsedUrl is inspected and
$database is set) to, after checking query params, fall back to parsing
$parsedUrl['path'] (trim leading '/' and cast to int) when no database/query
param is present; ensure you handle empty/invalid path values by leaving
$database as 0 and preserve existing behavior when query params explicitly
provide db/database.
- Around line 62-66: insert() currently casts hSetNx() to bool so it cannot
distinguish "field already exists" from a Redis error; update insert(string
$name, string $value) to disambiguate by either (A) checking the field first
with the Redis client (use hExists via
getRedisClient()->hExists($this->getHashKey(), $name)) and return false if it
exists, then call hSetNx and treat a false there as an error, or (B) wrap the
hSetNx call in a try/catch catching RedisException from
getRedisClient()->hSetNx($this->getHashKey(), $name, $value) and rethrow or
return a different signal; reference the insert method, getRedisClient(), hSetNx
and getHashKey() so you can locate and implement the chosen fix.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4cd5915 and 742afbf.

📒 Files selected for processing (4)
  • docs/administration.md
  • docs/installation.md
  • phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/SystemInformationController.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • phpmyfaq/src/phpMyFAQ/Controller/Administration/SystemInformationController.php

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (3)
phpmyfaq/translations/language_en.php (1)

1694-1698: DSN description still carries an implementation-specific parenthetical.

The prior review flagged the (Docker service name) annotation; this was updated to (use the service hostname). While the Docker reference is gone, the trailing parenthetical on the second example still ties explanatory context to only one of the two examples and can be confusing for users not using service-name-based DNS.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/translations/language_en.php` around lines 1694 - 1698, The DSN
examples in $LANG_CONF['storage.redisDsn'] include a parenthetical "(use the
service hostname)" only after the second example which implies
implementation-specific context; remove that parenthetical (or replace it with a
short neutral note that applies to both examples) so both example DSNs stand
alone and no deployment-specific hint is embedded in the example list.
docs/installation.md (1)

812-841: MD046 linter warnings still present on fenced code blocks — update markdownlint config.

The language specifier (text, bash) was added to satisfy MD040, but the project's markdownlint configuration still enforces style: indented, so MD046 fires for all four fenced blocks (lines 812, 818, 824, 837). As noted in prior review, the cleanest fix is to change the project-level markdownlint config to style: fenced or style: consistent rather than converting these blocks back to indented syntax.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/installation.md` around lines 812 - 841, Update the repository's
markdownlint configuration to set the code block style to "fenced" or
"consistent" (change the "style" option from "indented" to "fenced" or
"consistent") so MD046 no longer fires for the fenced code blocks shown (the
examples using language specifiers like "text" and "bash"); keep the fenced
blocks in docs/installation.md as-is and remove the ad-hoc conversions to
indented blocks.
phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php (1)

224-228: ⚠️ Potential issue | 🟠 Major

parse_url does not decode the password — auth() receives a percent-encoded string.

parse_url returns the raw percent-encoded value for the pass component; it does not URL-decode it. Any password that contains reserved characters (@, :, /, #, ?, [, ]) must be percent-encoded in the DSN for parse_url to extract it at all, but then the literal %40-style string gets forwarded to auth() instead of the actual password, causing an authentication failure wrapped as RuntimeException.

🐛 Proposed fix
         if (isset($parsedUrl['pass']) && $parsedUrl['pass'] !== '') {
-            if ($redis->auth($parsedUrl['pass']) !== true) {
+            if ($redis->auth(rawurldecode($parsedUrl['pass'])) !== true) {
                 throw new RuntimeException('Redis authentication failed for configuration storage.');
             }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php`
around lines 224 - 228, The RedisConfigurationStore currently passes the
percent-encoded password from parse_url directly into $redis->auth, causing auth
failures; update the code in RedisConfigurationStore where $parsedUrl['pass'] is
checked (the block that calls $redis->auth($parsedUrl['pass'])) to URL-decode
the password first (e.g., use rawurldecode/urldecode on $parsedUrl['pass']) and
then call $redis->auth with the decoded value, keeping the same error handling
that throws RuntimeException on auth failure.
🧹 Nitpick comments (1)
phpmyfaq/translations/language_en.php (1)

1671-1671: storageControlCenter is misplaced inside the Push Notifications block.

Line 1671 sits between $PMF_LANG['pushControlCenter'] and $PMF_LANG['msgGenerateVapidKeys'], which are both part of the "Web Push Notifications" section. The storage control-center label should either open its own versioned comment block or be co-located with the storage $LANG_CONF entries starting at line 1689.

✏️ Suggested improvement

Remove line 1671 from the push block and add it with the storage config entries:

 $PMF_LANG['pushControlCenter'] = 'Push';
-$PMF_LANG['storageControlCenter'] = 'Storage';
 $PMF_LANG['msgGenerateVapidKeys'] = 'Generate VAPID Keys';
+// added v4.2.0 - 2026-02-23 by Thorsten
+$PMF_LANG['storageControlCenter'] = 'Storage';
+
 $LANG_CONF['storage.useRedisForConfiguration'] = [
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@phpmyfaq/translations/language_en.php` at line 1671, The translation key
$PMF_LANG['storageControlCenter'] is currently placed inside the "Web Push
Notifications" block between $PMF_LANG['pushControlCenter'] and
$PMF_LANG['msgGenerateVapidKeys']; move this entry out of the push notifications
section and into the storage configuration area alongside the storage $LANG_CONF
entries (the block starting at the storage config entries around $LANG_CONF) so
that $PMF_LANG['storageControlCenter'] is co-located with other storage-related
labels and not mixed with $PMF_LANG['pushControlCenter'] or
$PMF_LANG['msgGenerateVapidKeys'].
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/installation.md`:
- Around line 845-851: Update the "Recommended Defaults" DSN documentation to
note that the example DSN value `tcp://redis:6379?database=1` is Docker-specific
(the hostname `redis` resolves only inside a Docker network); instruct users
running phpMyFAQ outside Docker to replace `redis` with their Redis host (e.g.,
`localhost`, an IP, or a configured hostname) or provide guidance to adjust the
DSN accordingly, and keep the Prefix `pmf:config:` and Timeout `1.0` unchanged
for clarity.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php`:
- Around line 195-247: The Redis client created in RedisConfigurationStore does
not set Redis::OPT_READ_TIMEOUT, so a stalled Redis server can block request
threads; after the successful connect/auth/select sequence (reference the $redis
variable in RedisConfigurationStore::... where connect/auth/select are called)
set the socket read timeout using $redis->setOption(Redis::OPT_READ_TIMEOUT,
<timeout>) using a sensible value (preferably a configured readTimeout in
$this->settings or fallback to (float)ini_get('default_socket_timeout')),
ensuring the option is applied before returning $redis and preserving existing
exception behavior.

---

Duplicate comments:
In `@docs/installation.md`:
- Around line 812-841: Update the repository's markdownlint configuration to set
the code block style to "fenced" or "consistent" (change the "style" option from
"indented" to "fenced" or "consistent") so MD046 no longer fires for the fenced
code blocks shown (the examples using language specifiers like "text" and
"bash"); keep the fenced blocks in docs/installation.md as-is and remove the
ad-hoc conversions to indented blocks.

In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php`:
- Around line 224-228: The RedisConfigurationStore currently passes the
percent-encoded password from parse_url directly into $redis->auth, causing auth
failures; update the code in RedisConfigurationStore where $parsedUrl['pass'] is
checked (the block that calls $redis->auth($parsedUrl['pass'])) to URL-decode
the password first (e.g., use rawurldecode/urldecode on $parsedUrl['pass']) and
then call $redis->auth with the decoded value, keeping the same error handling
that throws RuntimeException on auth failure.

In `@phpmyfaq/translations/language_en.php`:
- Around line 1694-1698: The DSN examples in $LANG_CONF['storage.redisDsn']
include a parenthetical "(use the service hostname)" only after the second
example which implies implementation-specific context; remove that parenthetical
(or replace it with a short neutral note that applies to both examples) so both
example DSNs stand alone and no deployment-specific hint is embedded in the
example list.

---

Nitpick comments:
In `@phpmyfaq/translations/language_en.php`:
- Line 1671: The translation key $PMF_LANG['storageControlCenter'] is currently
placed inside the "Web Push Notifications" block between
$PMF_LANG['pushControlCenter'] and $PMF_LANG['msgGenerateVapidKeys']; move this
entry out of the push notifications section and into the storage configuration
area alongside the storage $LANG_CONF entries (the block starting at the storage
config entries around $LANG_CONF) so that $PMF_LANG['storageControlCenter'] is
co-located with other storage-related labels and not mixed with
$PMF_LANG['pushControlCenter'] or $PMF_LANG['msgGenerateVapidKeys'].

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 742afbf and 1769e5b.

📒 Files selected for processing (5)
  • docs/installation.md
  • phpmyfaq/assets/templates/admin/configuration/macros.twig
  • phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php
  • phpmyfaq/translations/language_de.php
  • phpmyfaq/translations/language_en.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • phpmyfaq/assets/templates/admin/configuration/macros.twig

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
docs/installation.md (1)

812-841: MD046 linter warnings still active on fenced blocks.

All four fenced blocks (lines 812, 818, 824, 837) still trigger MD046 ("Expected: indented; Actual: fenced") because the project markdownlint config is set to style: indented. Language specifiers (text, bash) are now correctly applied, so MD040 is resolved. The remaining fix is updating the markdownlint configuration.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/installation.md` around lines 812 - 841, The MD046 failures come from
the repo markdownlint config enforcing indented code blocks while the docs use
fenced blocks; update the markdownlint config (e.g., .markdownlint.json /
.markdownlint.yml) to accept fenced blocks by setting MD046 to use "fenced" (or
disable MD046) so the fenced blocks (the tcp://..., 127.0.0.1, unix:///..., and
the bash block) no longer trigger errors; apply the change to the shared config
so all occurrences, including the fenced blocks in installation.md, comply with
the lint rules.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php`:
- Around line 224-228: The Redis auth block in RedisConfigurationStore.php
currently ignores $parsedUrl['user'] and does not urldecode credentials; update
the logic so that both $parsedUrl['user'] and $parsedUrl['pass'] are passed
through urldecode(), then call $redis->auth([$user, $pass]) when a username is
present and fall back to $redis->auth($pass) when only a password exists, and
still throw a RuntimeException if auth does not return true; reference the
$parsedUrl['user'],$parsedUrl['pass'] variables and the $redis->auth(...) call
to locate and replace the existing check.

---

Duplicate comments:
In `@docs/installation.md`:
- Around line 812-841: The MD046 failures come from the repo markdownlint config
enforcing indented code blocks while the docs use fenced blocks; update the
markdownlint config (e.g., .markdownlint.json / .markdownlint.yml) to accept
fenced blocks by setting MD046 to use "fenced" (or disable MD046) so the fenced
blocks (the tcp://..., 127.0.0.1, unix:///..., and the bash block) no longer
trigger errors; apply the change to the shared config so all occurrences,
including the fenced blocks in installation.md, comply with the lint rules.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1769e5b and 94cd895.

📒 Files selected for processing (2)
  • docs/installation.md
  • phpmyfaq/src/phpMyFAQ/Configuration/Storage/RedisConfigurationStore.php

@thorsten thorsten merged commit 9954900 into main Feb 23, 2026
9 checks passed
@thorsten thorsten deleted the feat/redis branch February 23, 2026 16:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant