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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ This is a log of major user-visible changes in each phpMyFAQ release.
- added support for SendGrid, AWS SES, and Mailgun (Thorsten)
- added theme manager with support for multiple themes and theme switching (Thorsten)
- added Symfony Kernel for better application structure and extensibility (Thorsten)
- added optional Redis support for configuration caching (Thorsten)
- added experimental support for API key authentication via OAuth2 (Thorsten)
- added experimental per-tenant quota enforcement, and API request rate limits (Thorsten)
- improved audit and activity log with comprehensive security event tracking (Thorsten)
Expand Down
23 changes: 19 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,15 @@ services:
- ./volumes/postgres:/var/lib/postgresql/data

redis:
image: redis:7-alpine
image: redis:8-alpine
restart: always
command: redis-server --appendonly yes
healthcheck:
test: [ 'CMD', 'redis-cli', 'ping' ]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
ports:
- '6379:6379'
volumes:
Expand Down Expand Up @@ -78,7 +84,10 @@ services:
volumes:
- ./phpmyfaq:/var/www/html
depends_on:
- pnpm
pnpm:
condition: service_completed_successfully
redis:
condition: service_started

nginx:
image: nginx:latest
Expand Down Expand Up @@ -123,7 +132,10 @@ services:
volumes:
- ./phpmyfaq:/var/www/html
depends_on:
- pnpm
pnpm:
condition: service_completed_successfully
redis:
condition: service_started

frankenphp:
profiles: ['frankenphp']
Expand Down Expand Up @@ -161,7 +173,10 @@ services:
- ./volumes/caddy_data:/data
- ./volumes/caddy_config:/config
depends_on:
- pnpm
pnpm:
condition: service_completed_successfully
redis:
condition: service_started

pnpm:
image: node:24-alpine
Expand Down
24 changes: 19 additions & 5 deletions docs/administration.md
Original file line number Diff line number Diff line change
Expand Up @@ -840,21 +840,35 @@ You can click through the update wizard:
5. Install downloaded package: first, it creates a backup of your current installation, then it copies the downloaded
files into your installation, and in the end, the database is updated

### 5.6.5 Elasticsearch configuration
### 5.6.5 Redis-backed configuration storage

The **Storage** tab allows enabling Redis as a configuration cache.

- The database table `faqconfig` remains the source of truth.
- Redis is used as fast read storage.
- If Redis is unavailable, phpMyFAQ falls back to database reads/writes.

Before enabling it in production:

1. Configure DSN, prefix, and timeout in **Configuration → Storage**
2. Use **Test Redis connection**
3. Save configuration and verify application behavior

### 5.6.6 Elasticsearch configuration

Here you can create and drop the Elasticsearch index, and you can run a full import of all data from your database
into the Elasticsearch index.
You can also see some Elasticsearch relevant usage data.
You can also see some Elasticsearch-relevant usage data.
This page is only available if Elasticsearch is enabled.

### 5.6.6 OpenSearch configuration
### 5.6.7 OpenSearch configuration

Here you can create and drop the OpenSearch index, and you can run a full import of all data from your database
into the OpenSearch index.
You can also see some OpenSearch relevant usage data.
You can also see some OpenSearch-relevant usage data.
This page is only available if OpenSearch is enabled.

### 5.6.7 System information
### 5.6.8 System information

On this page, phpMyFAQ displays some relevant system information like PHP version, database version, or session path.
Please use this information when reporting bugs.
Expand Down
87 changes: 86 additions & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Suggested PHP extensions:

- mbstring
- zlib
- redis (if you want to use Redis as an in-memory data store)

### Web server requirements

Expand Down Expand Up @@ -69,7 +70,7 @@ The PDO extension is the preferred way to connect to your database server.

### Optional In-Memory Data Store

- [Redis](https://redis.io/) 8.x or later
- [Redis](https://redis.io/) 7.x or later

### Additional requirements

Expand Down Expand Up @@ -783,3 +784,87 @@ frankenphp {
3. Restart FrankenPHP

Worker mode significantly improves performance by keeping PHP code in memory between requests.

## 2.20 Redis-Backed Configuration Storage

phpMyFAQ can optionally use Redis to cache configuration values from `faqconfig`.

- Source of truth remains the database (`faqconfig`)
- Redis is used as a fast read layer
- On writing, phpMyFAQ updates the database first, then Redis
- If Redis is unavailable, phpMyFAQ falls back to database reads/writes

### Admin Setup

Open **Administration → Configuration → Storage** and configure:

- `Enable Redis for configuration storage`
- `Redis DSN for configuration storage`
- `Redis key prefix for configuration storage`
- `Redis connection timeout in seconds`

Use **Test Redis connection** before enabling Redis storage.

### DSN Examples

Docker Compose service name:

```text
tcp://redis:6379?database=1
```

Local host:

```text
tcp://127.0.0.1:6379?database=1
```

Unix socket:

```text
unix:///var/run/redis/redis.sock?database=1
```

### Docker Notes

`redis` as hostname works only if:

- a `redis` service/container is running
- your PHP container and Redis container are in the same Docker network

Check quickly:

```bash
docker compose ps
docker compose exec php-fpm getent hosts redis
docker compose exec php-fpm sh -lc 'nc -zv redis 6379'
```

If `getaddrinfo for redis failed: Name or service not known`, either Redis is not running or hostname/networking is wrong.

### Recommended Defaults

- DSN: `tcp://redis:6379?database=1` (Docker) or `tcp://127.0.0.1:6379?database=1` (non-Docker). Replace the hostname with your Redis server address if it differs.
- Prefix: `pmf:config:`
- Timeout: `1.0`

Use a unique prefix per phpMyFAQ instance when sharing one Redis server.

### Troubleshooting

#### Test button returns login redirect

This means the session is no longer valid (HTTP 401). Log in again and retry.

#### Test button says Redis connection failed

Open browser dev tools and inspect the API JSON response for the concrete reason:

- DNS/hostname resolution issue
- connection refused (Redis not listening)
- timeout
- invalid DSN

#### Redis enabled, but the app still works when Redis is down

This is expected behavior: phpMyFAQ falls back to the database.
44 changes: 44 additions & 0 deletions phpmyfaq/admin/assets/src/api/configuration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
fetchTemplates,
fetchTranslations,
saveConfiguration,
sendTestMail,
testRedisConnection,
} from './configuration';
import * as fetchWrapperModule from './fetch-wrapper';

Expand Down Expand Up @@ -379,3 +381,45 @@ describe('saveConfiguration', () => {
});
});
});

describe('sendTestMail', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should send test mail request with csrf payload', async () => {
const mockResponse = { success: true, message: 'ok' };
vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse);

const result = await sendTestMail('csrf-token');

expect(result).toEqual(mockResponse);
expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/configuration/send-test-mail', {
method: 'POST',
body: JSON.stringify({ csrf: 'csrf-token' }),
});
});
});

describe('testRedisConnection', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should send redis connection test request with payload', async () => {
const mockResponse = { success: true, message: 'Redis connection successful.' };
vi.spyOn(fetchWrapperModule, 'fetchJson').mockResolvedValue(mockResponse);

const result = await testRedisConnection('csrf-token', 'tcp://redis:6379?database=1', 1.0);

expect(result).toEqual(mockResponse);
expect(fetchWrapperModule.fetchJson).toHaveBeenCalledWith('api/configuration/test-redis-connection', {
method: 'POST',
body: JSON.stringify({
csrf: 'csrf-token',
redisDsn: 'tcp://redis:6379?database=1',
timeout: 1.0,
}),
});
});
});
18 changes: 18 additions & 0 deletions phpmyfaq/admin/assets/src/api/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,21 @@ export const uploadThemeArchive = async (data: FormData): Promise<Response> => {
body: data,
})) as Response;
};

export const sendTestMail = async (csrf: string): Promise<Response> => {
return (await fetchJson('api/configuration/send-test-mail', {
method: 'POST',
body: JSON.stringify({ csrf }),
})) as Response;
};

export const testRedisConnection = async (csrf: string, redisDsn: string, timeout: number): Promise<Response> => {
return (await fetchJson('api/configuration/test-redis-connection', {
method: 'POST',
body: JSON.stringify({
csrf,
redisDsn,
timeout,
}),
})) as Response;
};
4 changes: 2 additions & 2 deletions phpmyfaq/admin/assets/src/api/fetch-wrapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ describe('fetchWrapper', () => {
await fetchWrapper('/test', { method: 'GET' });
expect.fail('Should have thrown an error');
} catch {
expect(window.location.href).toBe('./admin/login');
expect(window.location.href).toBe('./login');
}
});

Expand All @@ -152,7 +152,7 @@ describe('fetchWrapper', () => {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('Session expired');
expect(sessionStorageMock['loginMessage']).toBe('Your session has expired. Please log in again.');
expect(window.location.href).toBe('./admin/login');
expect(window.location.href).toBe('./login');
}
});
});
Expand Down
4 changes: 2 additions & 2 deletions phpmyfaq/admin/assets/src/api/fetch-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ export const fetchWrapper = async (url: string, options?: RequestInit): Promise<
// Store a flash message in sessionStorage to show after redirect
sessionStorage.setItem('loginMessage', 'Your session has expired. Please log in again.');

// Redirect to the login page
window.location.href = './admin/login';
// Redirect to the admin login page
window.location.href = './login';

// Throw error to stop further processing
throw new Error('Session expired');
Expand Down
59 changes: 59 additions & 0 deletions phpmyfaq/admin/assets/src/configuration/configuration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import {
handleSearchRelevance,
handleSeoMetaTags,
handleMailProvider,
handleSendTestMail,
handleTestRedisConnection,
setupRedisTestButtonState,
} from './configuration';
import {
fetchConfiguration,
Expand All @@ -28,6 +31,8 @@ import {
fetchTemplates,
fetchTranslations,
saveConfiguration,
sendTestMail,
testRedisConnection,
} from '../api';

vi.mock('../api');
Expand Down Expand Up @@ -221,6 +226,60 @@ describe('Configuration Functions', () => {
});
});

describe('handleSendTestMail', () => {
it('should call send test mail API with csrf token', async () => {
document.body.innerHTML = `
<input id="pmf-csrf-token" value="csrf-token" />
`;

(sendTestMail as Mock).mockResolvedValue({ success: true, message: 'ok' });

await handleSendTestMail();

expect(sendTestMail).toHaveBeenCalledWith('csrf-token');
});
});

describe('handleTestRedisConnection', () => {
it('should call redis test API with form values', async () => {
document.body.innerHTML = `
<input id="pmf-csrf-token" value="csrf-token" />
<input id="edit[storage.redisDsn]" value="tcp://redis:6379?database=1" />
<input id="edit[storage.redisConnectTimeout]" value="1.5" />
<button id="btn-phpmyfaq-storage-testRedisConnection">Test Redis connection</button>
`;

(testRedisConnection as Mock).mockResolvedValue({ success: true, message: 'ok' });

await handleTestRedisConnection();

expect(testRedisConnection).toHaveBeenCalledWith('csrf-token', 'tcp://redis:6379?database=1', 1.5);
const button = document.getElementById('btn-phpmyfaq-storage-testRedisConnection') as HTMLButtonElement;
expect(button.disabled).toBe(false);
expect(button.innerHTML).toContain('Test Redis connection');
});
});

describe('setupRedisTestButtonState', () => {
it('should disable button when DSN is empty and enable it when DSN is set', () => {
document.body.innerHTML = `
<input id="edit[storage.redisDsn]" value="" />
<button id="btn-phpmyfaq-storage-testRedisConnection">Test Redis connection</button>
`;

setupRedisTestButtonState();

const redisDsnInput = document.getElementById('edit[storage.redisDsn]') as HTMLInputElement;
const button = document.getElementById('btn-phpmyfaq-storage-testRedisConnection') as HTMLButtonElement;
expect(button.disabled).toBe(true);

redisDsnInput.value = 'tcp://redis:6379?database=1';
redisDsnInput.dispatchEvent(new Event('input'));

expect(button.disabled).toBe(false);
});
});

describe('handleConfiguration', () => {
it('should handle configuration tabs and load data', async () => {
document.body.innerHTML = `
Expand Down
Loading