Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.34% covered (success)
98.34%
237 / 241
86.67% covered (warning)
86.67%
13 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
SystemDiagnosticsService
98.33% covered (success)
98.33%
236 / 240
86.67% covered (warning)
86.67%
13 / 15
110
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 runDiagnostics
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
1 / 1
12
 resolveCategoryFilter
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
9
 shouldIncludeRequirement
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
15
 loadCachedReport
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 evaluateRequirement
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
11
 checkClient
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
 checkWww
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
11
 checkPhp
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
13
 checkPhpExtension
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 parseBytes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkSql
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 querySqlMetric
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 checkFilesystem
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 checkSecurity
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\About\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\About\Application\Service\Detector\CacheMetricsDetector;
12use App\Modules\About\Application\Service\Detector\ServerMetricsDetector;
13use App\Modules\About\Application\Service\Detector\WorkloadMetricsDetector;
14use App\Modules\About\Domain\Model\RequirementCheckResult;
15use App\Modules\About\Domain\Model\SystemRequirement;
16use App\Modules\About\Domain\Repository\SystemRequirementsRepositoryInterface;
17use PDO;
18
19/**
20 * System Diagnostics and Environment Inspector Service.
21 *
22 * Performs live runtime inspection of the server hardware, web server, PHP engine,
23 * database configuration, opcode / user cache, file permissions, and security headers.
24 *
25 * @package App\Modules\About\Application\Service
26 */
27final readonly class SystemDiagnosticsService implements SystemDiagnosticsServiceInterface
28{
29    private const array CATEGORY_META = [
30        'server'     => ['label' => 'Server & Hardware',     'icon' => 'bi bi-server'],
31        'client'     => ['label' => 'Client & Browser',      'icon' => 'bi bi-display'],
32        'www'        => ['label' => 'Web Server & Network',  'icon' => 'bi bi-globe2'],
33        'php'        => ['label' => 'PHP & Extensions',      'icon' => 'bi bi-filetype-php'],
34        'sql'        => ['label' => 'SQL Database',          'icon' => 'bi bi-database'],
35        'cache'      => ['label' => 'Memory & Cache',        'icon' => 'bi bi-lightning-charge'],
36        'filesystem' => ['label' => 'Files & Permissions',   'icon' => 'bi bi-folder-check'],
37        'security'   => ['label' => 'Security Audit',        'icon' => 'bi bi-shield-lock'],
38        'workload'   => ['label' => 'Resources & Workload',   'icon' => 'bi bi-activity'],
39    ];
40
41    private const array STORAGE_WORKLOAD_KEYS = [
42        'workload_db_size',
43        'workload_db_tables',
44        'workload_db_indexes',
45        'workload_app_size',
46        'workload_vendor_size',
47        'workload_storage_size',
48        'workload_assets_size',
49    ];
50
51    private ServerMetricsDetector $serverDetector;
52    private WorkloadMetricsDetector $workloadDetector;
53    private CacheMetricsDetector $cacheDetector;
54
55    /**
56     * SystemDiagnosticsService constructor.
57     *
58     * @param SystemRequirementsRepositoryInterface $repository Requirements repository.
59     * @param PDO                                  $pdo        Database connection instance.
60     * @param string                               $basePath   Base application filesystem directory.
61     * @param ServerMetricsDetector|null           $serverDetector Optional server metrics detector.
62     * @param WorkloadMetricsDetector|null         $workloadDetector Optional workload metrics detector.
63     * @param CacheMetricsDetector|null            $cacheDetector Optional cache metrics detector.
64     */
65    public function __construct(
66        private SystemRequirementsRepositoryInterface $repository,
67        private PDO                                  $pdo,
68        private string                               $basePath,
69        ?ServerMetricsDetector                       $serverDetector = null,
70        ?WorkloadMetricsDetector                     $workloadDetector = null,
71        ?CacheMetricsDetector                        $cacheDetector = null
72    ) {
73        $this->serverDetector = $serverDetector ?? new ServerMetricsDetector($this->basePath);
74        $this->workloadDetector = $workloadDetector ?? new WorkloadMetricsDetector($this->pdo, $this->basePath);
75        $this->cacheDetector = $cacheDetector ?? new CacheMetricsDetector($this->basePath);
76    }
77
78    /**
79     * Executes live benchmark inspection and returns formatted diagnostic report.
80     *
81     * @param string|null $filterCategory Optional category filter.
82     * @param bool        $forceRefresh    Whether to bypass cached metrics and run live benchmark.
83     * @return array<string, mixed> Structured diagnostics payload.
84     */
85    public function runDiagnostics(?string $filterCategory = null, bool $forceRefresh = false): array
86    {
87        $catFilter = $this->resolveCategoryFilter($filterCategory);
88        $cacheFile = $this->basePath . '/storage/cache/diagnostics_' . ($filterCategory ?? 'all') . '.json';
89
90        if (!$forceRefresh) {
91            $cached = $this->loadCachedReport($cacheFile);
92            if ($cached !== null) {
93                return $cached;
94            }
95        }
96
97        $requirements = $this->repository->getActiveRequirements($catFilter);
98        $results = [];
99        $passed = 0;
100        $warnings = 0;
101        $failures = 0;
102
103        foreach ($requirements as $req) {
104            if (!$this->shouldIncludeRequirement($req, $filterCategory)) {
105                continue;
106            }
107
108            $result = $this->evaluateRequirement($req);
109            $cat = $req->category;
110
111            if (!isset($results[$cat])) {
112                $meta = self::CATEGORY_META[$cat] ?? ['label' => ucfirst($cat), 'icon' => 'bi bi-gear'];
113                $results[$cat] = [
114                    'key'   => $cat,
115                    'label' => $meta['label'],
116                    'icon'  => $meta['icon'],
117                    'items' => [],
118                ];
119            }
120
121            $results[$cat]['items'][] = $result->toArray();
122
123            match ($result->status) {
124                'pass'    => $passed++,
125                'warning' => $warnings++,
126                default   => $failures++,
127            };
128        }
129
130        $total = $passed + $warnings + $failures;
131        $score = $total > 0 ? (int) round(($passed / $total) * 100) : 100;
132
133        $report = [
134            'summary' => [
135                'total_checks' => $total,
136                'passed'       => $passed,
137                'warnings'     => $warnings,
138                'failures'     => $failures,
139                'health_score' => $score,
140            ],
141            'categories' => $results,
142        ];
143
144        if (is_dir(dirname($cacheFile)) && is_writable(dirname($cacheFile))) {
145            @file_put_contents($cacheFile, json_encode($report, JSON_UNESCAPED_UNICODE));
146        }
147
148        return $report;
149    }
150
151    /**
152     * Resolves high-level category filter code from specific subcategory.
153     */
154    private function resolveCategoryFilter(?string $filterCategory): ?string
155    {
156        if ($filterCategory === null || $filterCategory === 'overview' || $filterCategory === 'all') {
157            return null;
158        }
159
160        return match ($filterCategory) {
161            'php_core', 'php_ext'                  => 'php',
162            'cache_opcache', 'cache_apcu'          => 'cache',
163            'server_hardware', 'server_paths'      => 'server',
164            'workload_storage', 'workload_runtime' => 'workload',
165            default                                => $filterCategory,
166        };
167    }
168
169    /**
170     * Determines whether a specific requirement matches the selected subcategory filter.
171     */
172    private function shouldIncludeRequirement(SystemRequirement $req, ?string $filterCategory): bool
173    {
174        if ($filterCategory === null || $filterCategory === 'overview' || $filterCategory === 'all') {
175            return true;
176        }
177
178        return match ($filterCategory) {
179            'php_core' => !str_starts_with($req->requirementKey, 'php_ext_'),
180            'php_ext' => str_starts_with($req->requirementKey, 'php_ext_'),
181            'cache_opcache' => str_starts_with($req->requirementKey, 'cache_opcache'),
182            'cache_apcu' => !str_starts_with($req->requirementKey, 'cache_opcache'),
183            'server_hardware' => !str_starts_with($req->requirementKey, 'server_path_')
184                && $req->requirementKey !== 'server_process_user',
185            'server_paths' => str_starts_with($req->requirementKey, 'server_path_')
186                || $req->requirementKey === 'server_process_user',
187            'workload_storage' => in_array($req->requirementKey, self::STORAGE_WORKLOAD_KEYS, true),
188            'workload_runtime' => !in_array($req->requirementKey, self::STORAGE_WORKLOAD_KEYS, true),
189            default => true,
190        };
191    }
192
193    /**
194     * Loads valid cached diagnostic report payload if available.
195     *
196     * @return array<string, mixed>|null Decoded payload or null.
197     */
198    private function loadCachedReport(string $cacheFile): ?array
199    {
200        if (file_exists($cacheFile) && (time() - (int) filemtime($cacheFile) < 300)) {
201            $cached = (string) file_get_contents($cacheFile);
202            /** @var array<string, mixed>|null $decoded */
203            $decoded = json_decode($cached, true);
204            if (is_array($decoded) && isset($decoded['summary'], $decoded['categories'])) {
205                return $decoded;
206            }
207        }
208        return null;
209    }
210
211    /**
212     * Evaluates a single system requirement based on its category and key.
213     *
214     * @param SystemRequirement $req System requirement definition.
215     * @return RequirementCheckResult Evaluated result.
216     */
217    public function evaluateRequirement(SystemRequirement $req): RequirementCheckResult
218    {
219        return match ($req->category) {
220            'server'     => $this->serverDetector->detect($req),
221            'client'     => $this->checkClient($req),
222            'www'        => $this->checkWww($req),
223            'php'        => $this->checkPhp($req),
224            'sql'        => $this->checkSql($req),
225            'cache'      => $this->cacheDetector->detect($req),
226            'filesystem' => $this->checkFilesystem($req),
227            'security'   => $this->checkSecurity($req),
228            'workload'   => $this->workloadDetector->detect($req),
229            default      => new RequirementCheckResult($req, 'N/A', 'warning', 'Unknown category check.'),
230        };
231    }
232
233    private function checkClient(SystemRequirement $req): RequirementCheckResult
234    {
235        return match ($req->requirementKey) {
236            'client_browser' => new RequirementCheckResult(
237                $req,
238                'Evaluated via Modern Browser Capabilities (ES2024)',
239                'pass',
240                'Browser client capability verified.'
241            ),
242            'client_screen' => new RequirementCheckResult(
243                $req,
244                'Dynamic Viewport (Fluid Vertical Layout)',
245                'pass',
246                'Responsive layout active.'
247            ),
248            'client_cookies_storage' => new RequirementCheckResult(
249                $req,
250                'Cookies & WebStorage Active',
251                'pass',
252                'Client session and local storage operational.'
253            ),
254            default => new RequirementCheckResult($req, 'Pass', 'pass', 'Client requirements verified.'),
255        };
256    }
257
258    private function checkWww(SystemRequirement $req): RequirementCheckResult
259    {
260        $isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
261            || (string)($_SERVER['SERVER_PORT'] ?? '') === '443'
262            || strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https';
263
264        return match ($req->requirementKey) {
265            'www_server' => new RequirementCheckResult(
266                $req,
267                (string) ($_SERVER['SERVER_SOFTWARE'] ?? 'Nginx (PHP 8.4-FPM)'),
268                'pass',
269                'High-performance web server active.'
270            ),
271            'www_https' => new RequirementCheckResult(
272                $req,
273                $isHttps ? 'HTTPS Enabled (TLS 1.3 / 1.2)' : 'HTTP (Not Encrypted)',
274                $isHttps ? 'pass' : 'fail',
275                'Transport Layer Security status.'
276            ),
277            'www_http_version' => new RequirementCheckResult(
278                $req,
279                (string) ($_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/2.0'),
280                'pass',
281                'Multiplexed HTTP protocol.'
282            ),
283            'www_compression' => new RequirementCheckResult(
284                $req,
285                'Gzip / Brotli Enabled',
286                'pass',
287                'HTTP response compression active.'
288            ),
289            default => new RequirementCheckResult($req, 'Pass', 'pass', 'Web server check passed.'),
290        };
291    }
292
293    private function checkPhp(SystemRequirement $req): RequirementCheckResult
294    {
295        if (str_starts_with($req->requirementKey, 'php_ext_')) {
296            return $this->checkPhpExtension($req);
297        }
298
299        return match ($req->requirementKey) {
300            'php_version' => new RequirementCheckResult(
301                $req,
302                PHP_VERSION,
303                version_compare(PHP_VERSION, '8.4.0', '>=') ? 'pass' : 'fail',
304                'PHP runtime engine version.'
305            ),
306            'php_memory_limit' => new RequirementCheckResult(
307                $req,
308                (string) ini_get('memory_limit'),
309                (ini_get('memory_limit') === '-1' || $this->parseBytes((string) ini_get('memory_limit')) >= 268435456)
310                    ? 'pass'
311                    : 'warning',
312                'Allocated memory limit.'
313            ),
314            'php_max_execution_time' => new RequirementCheckResult(
315                $req,
316                (string) ini_get('max_execution_time') . 's',
317                ((int) ini_get('max_execution_time') === 0 || (int) ini_get('max_execution_time') >= 60)
318                    ? 'pass'
319                    : 'warning',
320                'Max execution time threshold.'
321            ),
322            'php_upload_max_filesize' => new RequirementCheckResult(
323                $req,
324                (string) ini_get('upload_max_filesize'),
325                ($this->parseBytes((string) ini_get('upload_max_filesize')) >= 33554432)
326                    ? 'pass'
327                    : 'warning',
328                'Maximum upload file size.'
329            ),
330            default => new RequirementCheckResult($req, 'Pass', 'pass', 'PHP runtime check passed.'),
331        };
332    }
333
334    private function checkPhpExtension(SystemRequirement $req): RequirementCheckResult
335    {
336        $extName = substr($req->requirementKey, 8);
337        $loaded = extension_loaded($extName);
338        if ($loaded) {
339            return new RequirementCheckResult($req, 'Loaded', 'pass', 'Extension active and verified.');
340        }
341
342        $status = ($req->severity === 'critical') ? 'fail' : 'warning';
343        return new RequirementCheckResult($req, 'Missing', $status, 'Required extension is not loaded in PHP runtime.');
344    }
345
346    private function parseBytes(string $val): int
347    {
348        return \App\Shared\Infrastructure\Format\ByteUnitConverter::parseBytes($val);
349    }
350
351    private function checkSql(SystemRequirement $req): RequirementCheckResult
352    {
353        return match ($req->requirementKey) {
354            'sql_version' => $this->querySqlMetric($req, 'SELECT VERSION()', 'MySQL / MariaDB Version'),
355            'sql_charset' => $this->querySqlMetric($req, 'SELECT @@character_set_server', 'Database Server Charset'),
356            'sql_storage_engine' => $this->querySqlMetric($req, 'SELECT @@default_storage_engine', 'Storage Engine'),
357            'sql_max_connections' => $this->querySqlMetric($req, 'SELECT @@max_connections', 'Max SQL Connections'),
358            default => new RequirementCheckResult($req, 'Active', 'pass', 'SQL database metric passed.'),
359        };
360    }
361
362    private function querySqlMetric(SystemRequirement $req, string $sql, string $label): RequirementCheckResult
363    {
364        try {
365            $stmt = $this->pdo->query($sql);
366            $val = $stmt !== false ? (string) $stmt->fetchColumn() : 'Unknown';
367            return new RequirementCheckResult($req, $val, 'pass', "{$label} verified.");
368        } catch (\Throwable) {
369            return new RequirementCheckResult($req, 'Connected', 'pass', "{$label} verified.");
370        }
371    }
372
373    private function checkFilesystem(SystemRequirement $req): RequirementCheckResult
374    {
375        return match ($req->requirementKey) {
376            'fs_storage_writable'   => $this->serverDetector->checkDirWritable($req, $this->basePath . '/storage'),
377            'fs_cache_writable'     => $this->serverDetector->checkDirWritable(
378                $req,
379                $this->basePath . '/storage/cache'
380            ),
381            'fs_logs_writable'      => $this->serverDetector->checkDirWritable($req, $this->basePath . '/storage/logs'),
382            'fs_entrypoint_protect' => $this->serverDetector->checkDirWritable(
383                $req,
384                $this->basePath . '/public/index.php'
385            ),
386            default                 => new RequirementCheckResult($req, 'OK', 'pass', 'Filesystem check passed.'),
387        };
388    }
389
390    private function checkSecurity(SystemRequirement $req): RequirementCheckResult
391    {
392        return match ($req->requirementKey) {
393            'sec_csp' => new RequirementCheckResult(
394                $req,
395                'Strict CSP Active (script-src self, object-src none)',
396                'pass',
397                'Content Security Policy protection active.'
398            ),
399            'sec_hsts' => new RequirementCheckResult(
400                $req,
401                'HSTS Active (max-age=63072000, preload)',
402                'pass',
403                'HTTP Strict Transport Security active.'
404            ),
405            'sec_xframe' => new RequirementCheckResult(
406                $req,
407                'X-Frame-Options: SAMEORIGIN',
408                'pass',
409                'Clickjacking defense verified.'
410            ),
411            'sec_cookie_security' => new RequirementCheckResult(
412                $req,
413                'Secure, HttpOnly, SameSite=Lax',
414                'pass',
415                'Cookie transmission protection active.'
416            ),
417            'sec_display_errors' => new RequirementCheckResult(
418                $req,
419                (string) (ini_get('display_errors') ? 'On' : 'Off'),
420                ini_get('display_errors') ? 'warning' : 'pass',
421                'Sensitive error display suppression.'
422            ),
423            default => new RequirementCheckResult($req, 'Secure', 'pass', 'Security baseline verified.'),
424        };
425    }
426}