Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.83% covered (success)
89.83%
53 / 59
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
TenantQuotaEnforcer
89.83% covered (success)
89.83%
53 / 59
66.67% covered (warning)
66.67%
6 / 9
20.42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createFromDatabaseDriver
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 assertCanCreateFaq
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 assertCanCreateCategory
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 assertCanCreateUser
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 assertCanStoreAttachment
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 assertCountWithinLimit
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 readAttachmentSizeBytes
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
2.11
 extractFirstInt
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
5.93
1<?php
2
3/**
4 * Tenant quota enforcer.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-02-10
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Tenant;
21
22use phpMyFAQ\Database\DatabaseDriver;
23use RuntimeException;
24
25final readonly class TenantQuotaEnforcer
26{
27    public function __construct(
28        private DatabaseDriver $databaseDriver,
29        private TenantContext $tenantContext,
30    ) {
31    }
32
33    public static function createFromDatabaseDriver(DatabaseDriver $databaseDriver): self
34    {
35        return new self($databaseDriver, new TenantContextResolver()->resolve());
36    }
37
38    public function assertCanCreateFaq(): void
39    {
40        $this->assertCountWithinLimit($this->tenantContext->getQuotas()->getMaxFaqs(), 'faqdata', 'maxFaqs', 'FAQs');
41    }
42
43    public function assertCanCreateCategory(): void
44    {
45        $this->assertCountWithinLimit(
46            $this->tenantContext->getQuotas()->getMaxCategories(),
47            'faqcategories',
48            'maxCategories',
49            'categories',
50        );
51    }
52
53    public function assertCanCreateUser(): void
54    {
55        $this->assertCountWithinLimit($this->tenantContext->getQuotas()->getMaxUsers(), 'faquser', 'maxUsers', 'users');
56    }
57
58    public function assertCanStoreAttachment(int $newAttachmentSizeBytes): void
59    {
60        $maxAttachmentSizeMb = $this->tenantContext->getQuotas()->getMaxAttachmentSize();
61        if ($maxAttachmentSizeMb === null) {
62            return;
63        }
64
65        $currentSizeBytes = $this->readAttachmentSizeBytes();
66        $maxSizeBytes = $maxAttachmentSizeMb > (int) (PHP_INT_MAX / (1024 * 1024))
67            ? PHP_INT_MAX
68            : $maxAttachmentSizeMb * 1024 * 1024;
69
70        if (($currentSizeBytes + $newAttachmentSizeBytes) > $maxSizeBytes) {
71            throw new QuotaExceededException(sprintf(
72                'Tenant quota exceeded for maxAttachmentSize: %d MB (used: %d bytes, requested: %d bytes)',
73                $maxAttachmentSizeMb,
74                $currentSizeBytes,
75                $newAttachmentSizeBytes,
76            ));
77        }
78    }
79
80    private function assertCountWithinLimit(?int $limit, string $table, string $quotaKey, string $resourceName): void
81    {
82        if ($limit === null) {
83            return;
84        }
85
86        $query = sprintf('SELECT COUNT(1) AS amount FROM %s%s', $this->tenantContext->getTablePrefix(), $table);
87        $result = $this->databaseDriver->query($query);
88
89        if (!$result) {
90            throw new RuntimeException(sprintf(
91                'Failed to evaluate tenant quota for %s: %s',
92                $resourceName,
93                $this->databaseDriver->error(),
94            ));
95        }
96
97        $row = $this->databaseDriver->fetchArray($result);
98        $currentCount = $this->extractFirstInt($row);
99
100        if ($currentCount >= $limit) {
101            throw new QuotaExceededException(sprintf(
102                'Tenant quota exceeded for %s: limit=%d current=%d',
103                $quotaKey,
104                $limit,
105                $currentCount,
106            ));
107        }
108    }
109
110    private function readAttachmentSizeBytes(): int
111    {
112        $query = sprintf(
113            'SELECT COALESCE(SUM(filesize), 0) AS amount FROM %sfaqattachment',
114            $this->tenantContext->getTablePrefix(),
115        );
116        $result = $this->databaseDriver->query($query);
117
118        if (!$result) {
119            throw new RuntimeException(
120                'Failed to evaluate tenant quota for attachments: ' . $this->databaseDriver->error(),
121            );
122        }
123
124        return $this->extractFirstInt($this->databaseDriver->fetchArray($result));
125    }
126
127    private function extractFirstInt(array|false|null $row): int
128    {
129        if ($row === false || $row === null || $row === []) {
130            return 0;
131        }
132
133        $firstValue = reset($row);
134        if ($firstValue === false) {
135            return 0;
136        }
137
138        return (int) $firstValue;
139    }
140}