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
138 changes: 138 additions & 0 deletions phpmyfaq/src/phpMyFAQ/Http/RateLimiter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php

/**
* API rate limiter.
*
* This Source Code Form is subject to the terms of the Mozilla Public License,
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
* obtain one at https://mozilla.org/MPL/2.0/.
*
* @package phpMyFAQ
* @author Thorsten Rinne <[email protected]>
* @copyright 2026 phpMyFAQ Team
* @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
* @link https://www.phpmyfaq.de
* @since 2026-02-09
*/

declare(strict_types=1);

namespace phpMyFAQ\Http;

use phpMyFAQ\Configuration;
use phpMyFAQ\Database;

final class RateLimiter
{
/** @var array<string, int|string> */
private array $headersStorage = [];

/** @var array<string, int|string> */
public array $headers {
get => $this->headersStorage;
}

public function __construct(
private readonly Configuration $configuration,
) {
}

/**
* Checks if a request should be allowed for the given key.
*/
public function check(string $key, int $limit, int $intervalSeconds): bool
{
$limit = max(1, $limit);
$intervalSeconds = max(1, $intervalSeconds);

$db = $this->configuration->getDb();
$escapedKey = $db->escape($key);
$table = Database::getTablePrefix() . 'faqrate_limits';

$now = time();
$windowStart = (int) (floor($now / $intervalSeconds) * $intervalSeconds);
$windowReset = $windowStart + $intervalSeconds;

// Attempt INSERT for a new window (atomic — either succeeds or fails on duplicate key)
$insertQuery = sprintf(
"INSERT INTO %s (rate_key, window_start, requests, created) VALUES ('%s', %d, 1, %s)",
$table,
$escapedKey,
$windowStart,
$db->now(),
);

if ($db->query($insertQuery) === false) {
// Row already exists — atomically increment using the DB's current value
$updateQuery = sprintf(
"UPDATE %s SET requests = requests + 1 WHERE rate_key = '%s' AND window_start = %d",
$table,
$escapedKey,
$windowStart,
);

if ($db->query($updateQuery) === false) {
// DB write failed — deny the request (fail-closed)
$this->headersStorage = [
'X-RateLimit-Limit' => $limit,
'X-RateLimit-Remaining' => 0,
'X-RateLimit-Reset' => $windowReset,
'Retry-After' => max(1, $windowReset - $now),
];

return false;
}
}

// Read the authoritative post-increment count
$selectQuery = sprintf(
"SELECT requests FROM %s WHERE rate_key = '%s' AND window_start = %d",
$table,
$escapedKey,
$windowStart,
);
$result = $db->query($selectQuery);
$row = $result !== false ? $db->fetchObject($result) : false;

if (!is_object($row) || !isset($row->requests)) {
// Cannot read authoritative count — deny the request (fail-closed)
$this->headersStorage = [
'X-RateLimit-Limit' => $limit,
'X-RateLimit-Remaining' => 0,
'X-RateLimit-Reset' => $windowReset,
'Retry-After' => max(1, $windowReset - $now),
];

return false;
}

$currentRequests = (int) $row->requests;

if ($currentRequests > $limit) {
$this->headersStorage = [
'X-RateLimit-Limit' => $limit,
'X-RateLimit-Remaining' => 0,
'X-RateLimit-Reset' => $windowReset,
'Retry-After' => max(1, $windowReset - $now),
];

return false;
}

$this->headersStorage = [
'X-RateLimit-Limit' => $limit,
'X-RateLimit-Remaining' => max(0, $limit - $currentRequests),
'X-RateLimit-Reset' => $windowReset,
];

return true;
}

/**
* @return array<string, int|string>
*/
public function getHeaders(): array
{
return $this->headers;
}
}
68 changes: 0 additions & 68 deletions phpmyfaq/src/phpMyFAQ/Instance/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,58 +37,6 @@ class Database
*/
private static ?DriverInterface $driver = null;

/**
* DROP TABLE statements.
*/
private array $dropTableStmts = [
'DROP TABLE %sfaqadminlog',
'DROP TABLE %sfaqattachment',
'DROP TABLE %sfaqattachment_file',
'DROP TABLE %sfaqapi_keys',
'DROP TABLE %sfaqoauth_clients',
'DROP TABLE %sfaqoauth_scopes',
'DROP TABLE %sfaqoauth_access_tokens',
'DROP TABLE %sfaqoauth_refresh_tokens',
'DROP TABLE %sfaqoauth_auth_codes',
'DROP TABLE %sfaqbackup',
'DROP TABLE %sfaqcaptcha',
'DROP TABLE %sfaqcategories',
'DROP TABLE %sfaqcategoryrelations',
'DROP TABLE %sfaqcategory_group',
'DROP TABLE %sfaqcategory_user',
'DROP TABLE %sfaqchanges',
'DROP TABLE %sfaqchat_messages',
'DROP TABLE %sfaqcomments',
'DROP TABLE %sfaqconfig',
'DROP TABLE %sfaqdata',
'DROP TABLE %sfaqdata_revisions',
'DROP TABLE %sfaqdata_group',
'DROP TABLE %sfaqdata_tags',
'DROP TABLE %sfaqdata_user',
'DROP TABLE %sfaqglossary',
'DROP TABLE %sfaqgroup',
'DROP TABLE %sfaqgroup_right',
'DROP TABLE %sfaqinstances',
'DROP TABLE %sfaqinstances_config',
'DROP TABLE %sfaqmigrations',
'DROP TABLE %sfaqnews',
'DROP TABLE %sfaqpush_subscriptions',
'DROP TABLE %sfaqquestions',
'DROP TABLE %sfaqright',
'DROP TABLE %sfaqsearches',
'DROP TABLE %sfaqseo',
'DROP TABLE %sfaqsessions',
'DROP TABLE %sfaqstopwords',
'DROP TABLE %sfaqtags',
'DROP TABLE %sfaquser',
'DROP TABLE %sfaquserdata',
'DROP TABLE %sfaquserlogin',
'DROP TABLE %sfaquser_group',
'DROP TABLE %sfaquser_right',
'DROP TABLE %sfaqvisits',
'DROP TABLE %sfaqvoting',
];

/**
* Constructor.
*/
Expand Down Expand Up @@ -173,20 +121,4 @@ public static function createTenantDatabase(Configuration $configuration, string
$type,
));
}

/**
* Executes all DROP TABLE statements.
*/
public function dropTables(string $prefix = ''): bool
{
foreach ($this->dropTableStmts as $dropTableStmt) {
$result = $this->configuration->getDb()->query(sprintf($dropTableStmt, $prefix));

if (!$result) {
return false;
}
}

return true;
}
}
12 changes: 12 additions & 0 deletions phpmyfaq/src/phpMyFAQ/Setup/Installation/DatabaseSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public function getAllTables(): array
'faqcustompages' => $this->faqcustompages(),
'faqquestions' => $this->faqquestions(),
'faqright' => $this->faqright(),
'faqrate_limits' => $this->faqrateLimits(),
'faqsearches' => $this->faqsearches(),
'faqseo' => $this->faqseo(),
'faqsessions' => $this->faqsessions(),
Expand Down Expand Up @@ -581,6 +582,17 @@ public function faqright(): TableBuilder
->primaryKey('right_id');
}

public function faqrateLimits(): TableBuilder
{
return new TableBuilder($this->dialect)
->table('faqrate_limits')
->varchar('rate_key', 255, false)
->integer('window_start', false)
->integer('requests', false, 0)
->timestamp('created')
->primaryKey(['rate_key', 'window_start']);
}

public function faqsearches(): TableBuilder
{
return new TableBuilder($this->dialect)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ private static function buildDefaultConfig(): array
'api.apiClientToken' => '',
'api.onlyActiveFaqs' => 'true',
'api.onlyActiveCategories' => 'true',
'api.rateLimit.requests' => '100',
'api.rateLimit.interval' => '3600',
'translation.provider' => 'none',
'translation.googleApiKey' => '',
'translation.deeplApiKey' => '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public function getDependencies(): array

public function getDescription(): string
{
return 'Admin log hash columns, custom pages, chat messages, translation config, API keys, OAuth2 tables';
return 'Admin log hash columns, custom pages, chat messages, translation config, API rate limiting, API keys, OAuth2 tables';
}

public function up(OperationRecorder $recorder): void
Expand Down Expand Up @@ -207,6 +207,8 @@ public function up(OperationRecorder $recorder): void
$recorder->addConfig('api.onlyActiveCategories', 'true');
$recorder->addConfig('api.onlyPublicQuestions', 'true');
$recorder->addConfig('api.ignoreOrphanedFaqs', 'true');
$recorder->addConfig('api.rateLimit.requests', '100');
$recorder->addConfig('api.rateLimit.interval', '3600');

// Translation service configuration
$recorder->addConfig('translation.provider', 'none');
Expand Down Expand Up @@ -520,6 +522,55 @@ public function up(OperationRecorder $recorder): void
$recorder->addConfig('push.vapidPrivateKey', '');
$recorder->addConfig('push.vapidSubject', '');

// Create API rate-limit table
if ($this->isMySql()) {
$recorder->addSql(sprintf(
'CREATE TABLE IF NOT EXISTS %sfaqrate_limits (
rate_key VARCHAR(255) NOT NULL,
window_start INT(11) NOT NULL,
requests INT(11) NOT NULL DEFAULT 0,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (rate_key, window_start)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB',
$this->tablePrefix,
), 'Create API rate limits table (MySQL)');
} elseif ($this->isPostgreSql()) {
$recorder->addSql(sprintf(
'CREATE TABLE IF NOT EXISTS %sfaqrate_limits (
rate_key VARCHAR(255) NOT NULL,
window_start INTEGER NOT NULL,
requests INTEGER NOT NULL DEFAULT 0,
created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (rate_key, window_start)
)',
$this->tablePrefix,
), 'Create API rate limits table (PostgreSQL)');
} elseif ($this->isSqlite()) {
$recorder->addSql(sprintf('CREATE TABLE IF NOT EXISTS %sfaqrate_limits (
rate_key VARCHAR(255) NOT NULL,
window_start INTEGER NOT NULL,
requests INTEGER NOT NULL DEFAULT 0,
created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (rate_key, window_start)
)', $this->tablePrefix), 'Create API rate limits table (SQLite)');
} elseif ($this->isSqlServer()) {
$recorder->addSql(
sprintf(
"IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'%sfaqrate_limits') AND type = 'U') "
. 'CREATE TABLE %sfaqrate_limits (
rate_key VARCHAR(255) NOT NULL,
window_start INT NOT NULL,
requests INT NOT NULL DEFAULT 0,
created DATETIME NOT NULL DEFAULT GETDATE(),
PRIMARY KEY (rate_key, window_start)
)',
$this->tablePrefix,
$this->tablePrefix,
),
'Create API rate limits table (SQL Server)',
);
}

// Create API keys table
if ($this->isMySql()) {
$recorder->addSql(sprintf(
Expand Down
14 changes: 0 additions & 14 deletions phpmyfaq/src/phpMyFAQ/System.php
Original file line number Diff line number Diff line change
Expand Up @@ -446,18 +446,4 @@ public function createHashes(): string

return json_encode($hashes, JSON_THROW_ON_ERROR);
}

/**
* Drops all given tables
*
* @param array<string> $queries
*/
public function dropTables(array $queries): void
{
if ($this->databaseDriver instanceof DatabaseDriver) {
foreach ($queries as $query) {
$this->databaseDriver->query($query);
}
}
}
}
5 changes: 5 additions & 0 deletions phpmyfaq/src/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
use phpMyFAQ\Faq\Statistics;
use phpMyFAQ\Forms;
use phpMyFAQ\Glossary;
use phpMyFAQ\Http\RateLimiter;
use phpMyFAQ\Helper\CategoryHelper;
use phpMyFAQ\Helper\FaqHelper;
use phpMyFAQ\Helper\QuestionHelper;
Expand Down Expand Up @@ -321,6 +322,10 @@
service('phpmyfaq.configuration'),
]);

$services->set('phpmyfaq.http.rate-limiter', RateLimiter::class)->args([
service('phpmyfaq.configuration'),
]);

$services->set('phpmyfaq.helper.category-helper', CategoryHelper::class);

$services->set('phpmyfaq.helper.faq', FaqHelper::class)->args([
Expand Down
3 changes: 3 additions & 0 deletions phpmyfaq/translations/language_en.php
Original file line number Diff line number Diff line change
Expand Up @@ -1672,4 +1672,7 @@
$PMF_LANG['msgVapidKeysGenerated'] = 'VAPID keys have been generated successfully.';
$PMF_LANG['msgVapidKeysError'] = 'Failed to generate VAPID keys.';

$LANG_CONF['api.rateLimit.requests'] = ['input', 'API rate limit', 'Standard: 100 requests'];
$LANG_CONF['api.rateLimit.interval'] = ['input', 'API rate limit interval in seconds', 'Standard: 3600 seconds'];

return $PMF_LANG;
Loading
Loading