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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ This is a log of major user-visible changes in each phpMyFAQ release.
- added a simple chat for users (Thorsten)
- added push notifications via Web Push API (Thorsten)
- added support for Flesch readability tests (Thorsten)
- added storage abstraction layer with support for local filesystem, and Amazon S3 (Thorsten)
- added experimental support for API key authentication via OAuth2 (Thorsten)
- added experimental per-tenant quota enforcement covering max FAQs, categories, users, attachment size, and API request rate limits (Thorsten)
- improved audit and activity log with comprehensive security event tracking (Thorsten)
- improved API errors with formatted RFC 7807 Problem Details JSON responses (Thorsten)
- improved support for PDO (Thorsten)
- improved sticky FAQs administration (Thorsten)
- improved update process (Thorsten)
- improved and hardened multi tenancy support (Thorsten)
- migrated codebase using PHP 8.4 language features (Thorsten)
- migrated routes using PHP 8+ #[Route] attributes (Thorsten)

Expand Down
8 changes: 8 additions & 0 deletions phpmyfaq/src/phpMyFAQ/Attachment/AbstractAttachment.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

use phpMyFAQ\Database;
use phpMyFAQ\Database\DatabaseDriver;
use phpMyFAQ\Tenant\TenantQuotaEnforcer;

/**
* Class AttachmentAbstract
Expand Down Expand Up @@ -94,6 +95,7 @@ abstract class AbstractAttachment
* Attachment file mime type.
*/
protected string $mimeType = '';
private ?TenantQuotaEnforcer $tenantQuotaEnforcer = null;

/**
* Constructor.
Expand Down Expand Up @@ -232,6 +234,7 @@ public function saveMeta(): int
$attachmentTableName = sprintf('%sfaqattachment', Database::getTablePrefix());

if (null === $this->id) {
$this->getTenantQuotaEnforcer()->assertCanStoreAttachment($this->filesize);
$this->id = $this->databaseDriver->nextId($attachmentTableName, 'id');

$sql = sprintf(
Expand Down Expand Up @@ -261,6 +264,11 @@ public function saveMeta(): int
return $this->id;
}

protected function getTenantQuotaEnforcer(): TenantQuotaEnforcer
{
return $this->tenantQuotaEnforcer ??= TenantQuotaEnforcer::createFromDatabaseDriver($this->databaseDriver);
}

public function getFilename(): string
{
return $this->filename;
Expand Down
10 changes: 10 additions & 0 deletions phpmyfaq/src/phpMyFAQ/Category/CategoryRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@
use phpMyFAQ\Configuration;
use phpMyFAQ\Database;
use phpMyFAQ\Entity\CategoryEntity;
use phpMyFAQ\Tenant\TenantQuotaEnforcer;

class CategoryRepository implements CategoryRepositoryInterface
{
private ?TenantQuotaEnforcer $tenantQuotaEnforcer = null;

public function __construct(
private readonly Configuration $configuration,
) {
Expand Down Expand Up @@ -353,6 +356,8 @@ public function findCategoryIdByName(string $categoryName): ?int

public function create(CategoryEntity $categoryEntity): ?int
{
$this->getTenantQuotaEnforcer()->assertCanCreateCategory();

if ($categoryEntity->getId() === 0) {
$categoryEntity->setId($this->configuration->getDb()->nextId(
Database::getTablePrefix() . 'faqcategories',
Expand Down Expand Up @@ -380,6 +385,11 @@ public function create(CategoryEntity $categoryEntity): ?int
return $categoryEntity->getId();
}

private function getTenantQuotaEnforcer(): TenantQuotaEnforcer
{
return $this->tenantQuotaEnforcer ??= TenantQuotaEnforcer::createFromDatabaseDriver($this->configuration->getDb());
}

public function update(CategoryEntity $categoryEntity): bool
{
$query = sprintf(
Expand Down
9 changes: 9 additions & 0 deletions phpmyfaq/src/phpMyFAQ/Faq.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
use phpMyFAQ\Language\Plurals;
use phpMyFAQ\Link\Util\TitleSlugifier;
use phpMyFAQ\Pagination\UrlConfig;
use phpMyFAQ\Tenant\TenantQuotaEnforcer;
use stdClass;

/*
Expand Down Expand Up @@ -91,6 +92,7 @@ class Faq
* Flag for Group support.
*/
private bool $groupSupport = false;
private ?TenantQuotaEnforcer $tenantQuotaEnforcer = null;

/**
* Constructor.
Expand Down Expand Up @@ -915,6 +917,8 @@ public function getFaqByIdAndCategoryId(int $faqId, int $categoryId): array
*/
public function create(FaqEntity $faqEntity): FaqEntity
{
$this->getTenantQuotaEnforcer()->assertCanCreateFaq();

if (is_null($faqEntity->getId())) {
$faqEntity->setId($this->configuration->getDb()->nextId(Database::getTablePrefix() . 'faqdata', 'id'));
}
Expand Down Expand Up @@ -957,6 +961,11 @@ public function create(FaqEntity $faqEntity): FaqEntity
return $faqEntity;
}

private function getTenantQuotaEnforcer(): TenantQuotaEnforcer
{
return $this->tenantQuotaEnforcer ??= TenantQuotaEnforcer::createFromDatabaseDriver($this->configuration->getDb());
}

/**
* Gets the latest solution id for a FAQ record.
*/
Expand Down
24 changes: 24 additions & 0 deletions phpmyfaq/src/phpMyFAQ/Tenant/QuotaExceededException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

/**
* Exception for tenant quota violations.
*
* 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 <thorsten@phpmyfaq.de>
* @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-10
*/

declare(strict_types=1);

namespace phpMyFAQ\Tenant;

use RuntimeException;

class QuotaExceededException extends RuntimeException {}
140 changes: 140 additions & 0 deletions phpmyfaq/src/phpMyFAQ/Tenant/TenantQuotaEnforcer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php

/**
* Tenant quota enforcer.
*
* 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 <thorsten@phpmyfaq.de>
* @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-10
*/

declare(strict_types=1);

namespace phpMyFAQ\Tenant;

use phpMyFAQ\Database\DatabaseDriver;
use RuntimeException;

final readonly class TenantQuotaEnforcer
{
public function __construct(
private DatabaseDriver $databaseDriver,
private TenantContext $tenantContext,
) {
}

public static function createFromDatabaseDriver(DatabaseDriver $databaseDriver): self
{
return new self($databaseDriver, new TenantContextResolver()->resolve());
}

public function assertCanCreateFaq(): void
{
$this->assertCountWithinLimit($this->tenantContext->getQuotas()->getMaxFaqs(), 'faqdata', 'maxFaqs', 'FAQs');
}

public function assertCanCreateCategory(): void
{
$this->assertCountWithinLimit(
$this->tenantContext->getQuotas()->getMaxCategories(),
'faqcategories',
'maxCategories',
'categories',
);
}

public function assertCanCreateUser(): void
{
$this->assertCountWithinLimit($this->tenantContext->getQuotas()->getMaxUsers(), 'faquser', 'maxUsers', 'users');
}

public function assertCanStoreAttachment(int $newAttachmentSizeBytes): void
{
$maxAttachmentSizeMb = $this->tenantContext->getQuotas()->getMaxAttachmentSize();
if ($maxAttachmentSizeMb === null) {
return;
}

$currentSizeBytes = $this->readAttachmentSizeBytes();
$maxSizeBytes = $maxAttachmentSizeMb > (int) (PHP_INT_MAX / (1024 * 1024))
? PHP_INT_MAX
: $maxAttachmentSizeMb * 1024 * 1024;

if (($currentSizeBytes + $newAttachmentSizeBytes) > $maxSizeBytes) {
throw new QuotaExceededException(sprintf(
'Tenant quota exceeded for maxAttachmentSize: %d MB (used: %d bytes, requested: %d bytes)',
$maxAttachmentSizeMb,
$currentSizeBytes,
$newAttachmentSizeBytes,
));
}
}

private function assertCountWithinLimit(?int $limit, string $table, string $quotaKey, string $resourceName): void
{
if ($limit === null) {
return;
}

$query = sprintf('SELECT COUNT(1) AS amount FROM %s%s', $this->tenantContext->getTablePrefix(), $table);
$result = $this->databaseDriver->query($query);

if (!$result) {
throw new RuntimeException(sprintf(
'Failed to evaluate tenant quota for %s: %s',
$resourceName,
$this->databaseDriver->error(),
));
}

$row = $this->databaseDriver->fetchArray($result);
$currentCount = $this->extractFirstInt($row);

if ($currentCount >= $limit) {
throw new QuotaExceededException(sprintf(
'Tenant quota exceeded for %s: limit=%d current=%d',
$quotaKey,
$limit,
$currentCount,
));
}
}

private function readAttachmentSizeBytes(): int
{
$query = sprintf(
'SELECT COALESCE(SUM(filesize), 0) AS amount FROM %sfaqattachment',
$this->tenantContext->getTablePrefix(),
);
$result = $this->databaseDriver->query($query);

if (!$result) {
throw new RuntimeException(
'Failed to evaluate tenant quota for attachments: ' . $this->databaseDriver->error(),
);
}

return $this->extractFirstInt($this->databaseDriver->fetchArray($result));
}

private function extractFirstInt(array|false|null $row): int
{
if ($row === false || $row === null || $row === []) {
return 0;
}

$firstValue = reset($row);
if ($firstValue === false) {
return 0;
}

return (int) $firstValue;
}
}
9 changes: 9 additions & 0 deletions phpmyfaq/src/phpMyFAQ/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use phpMyFAQ\Auth\AuthDriverInterface;
use phpMyFAQ\Permission\MediumPermission;
use phpMyFAQ\Permission\PermissionInterface;
use phpMyFAQ\Tenant\TenantQuotaEnforcer;
use phpMyFAQ\User\UserData;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
Expand Down Expand Up @@ -135,6 +136,7 @@ class User

/** @var string $authSource Authentication, e.g. local, ldap, azure, sso, ... */
private string $authSource = 'local';
private ?TenantQuotaEnforcer $tenantQuotaEnforcer = null;

/**
* array of allowed values for status.
Expand Down Expand Up @@ -378,6 +380,8 @@ public function createUser(string $login, string $pass = '', string $domain = ''
}
}

$this->getTenantQuotaEnforcer()->assertCanCreateUser();

// set user-ID
if (0 === $userId) {
$this->userId = $this->configuration->getDb()->nextId(Database::getTablePrefix() . 'faquser', 'user_id');
Expand Down Expand Up @@ -434,6 +438,11 @@ public function createUser(string $login, string $pass = '', string $domain = ''
return $this->getUserByLogin($login, false);
}

private function getTenantQuotaEnforcer(): TenantQuotaEnforcer
{
return $this->tenantQuotaEnforcer ??= TenantQuotaEnforcer::createFromDatabaseDriver($this->configuration->getDb());
}

/**
* Returns true if login is a valid login string.
* $this->loginMinLength defines the minimum length of the login string.
Expand Down
46 changes: 46 additions & 0 deletions tests/phpMyFAQ/Attachment/AbstractAttachmentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace phpMyFAQ\Attachment;

use phpMyFAQ\Database\DatabaseDriver;
use phpMyFAQ\Tenant\QuotaExceededException;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
Expand Down Expand Up @@ -63,6 +64,12 @@ public function testPostUpdateMeta(): void
};
}

protected function tearDown(): void
{
parent::tearDown();
putenv('PMF_TENANT_QUOTA_MAX_ATTACHMENT_SIZE');
}

public function testConstructorWithoutAttachmentId(): void
{
$attachment = new class($this->mockDb) extends AbstractAttachment {
Expand Down Expand Up @@ -294,6 +301,45 @@ public function testReadMimeType(): void
$this->assertEquals($mimeType, $this->attachment->getMimeType());
}

public function testSaveMetaThrowsWhenAttachmentQuotaIsExceeded(): void
{
putenv('PMF_TENANT_QUOTA_MAX_ATTACHMENT_SIZE=0');

$reflection = new ReflectionClass($this->attachment);
$properties = [
'recordId' => 123,
'recordLang' => 'en',
'realHash' => 'real123',
'virtualHash' => 'virtual456',
'passwordHash' => 'pwd789',
'filename' => 'test.pdf',
'filesize' => 1,
'encrypted' => true,
'mimeType' => 'application/pdf',
];

foreach ($properties as $prop => $value) {
$reflection->getProperty($prop)->setValue($this->attachment, $value);
}

$reflection->getProperty('id')->setValue($this->attachment, null);

$this->mockDb
->expects($this->once())
->method('query')
->with($this->stringContains('SELECT COALESCE(SUM(filesize), 0)'))
->willReturn(true);
$this->mockDb
->expects($this->once())
->method('fetchArray')
->with(true)
->willReturn(['amount' => 0]);
$this->mockDb->expects($this->never())->method('nextId');

$this->expectException(QuotaExceededException::class);
$this->attachment->saveMeta();
}

public function testMkVirtualHashUnencrypted(): void
{
$reflection = new ReflectionClass($this->attachment);
Expand Down
Loading